feat: add heartbeat monitoring system and disable browser timer throttling

This commit is contained in:
Megghy
2025-10-04 01:27:47 +08:00
parent fc465a8e0d
commit 389515bc5b
14 changed files with 246 additions and 532 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,425 +0,0 @@
import DefaultIndexTemplateVue from '@/views/view/indexTemplate/DefaultIndexTemplate.vue';
import { defineAsyncComponent, ref, markRaw } from 'vue';
const debugAPI =
import.meta.env.VITE_API == 'dev'
? import.meta.env.VITE_DEBUG_DEV_API
: import.meta.env.VITE_DEBUG_RELEASE_API;
const releseAPI = `https://vtsuru.suki.club/`;
const failoverAPI = `https://failover-api.vtsuru.suki.club/`;
export const isBackendUsable = ref(true);
export const isDev = import.meta.env.MODE === 'development';
// @ts-ignore
export const isTauri = () => window.__TAURI__ !== undefined || window.__TAURI_INTERNAL__ !== undefined || '__TAURI__' in window;
export const AVATAR_URL = 'https://workers.vrp.moe/api/bilibili/avatar/';
export const FILE_BASE_URL = 'https://files.vtsuru.suki.club';
export const IMGUR_URL = FILE_BASE_URL + '/imgur/';
export const THINGS_URL = FILE_BASE_URL + '/things/';
export const apiFail = ref(false);
export const BASE_URL =
process.env.NODE_ENV === 'development'
? debugAPI
: apiFail.value
? failoverAPI
: releseAPI;
export const BASE_API_URL = BASE_URL + 'api/';
export const FETCH_API = 'https://fetch.vtsuru.live/';
export const BASE_HUB_URL =
(process.env.NODE_ENV === 'development'
? debugAPI
: apiFail.value
? failoverAPI
: releseAPI) + 'hub/';
export const TURNSTILE_KEY = '0x4AAAAAAAETUSAKbds019h0';
export const CURRENT_HOST = `${window.location.protocol}//${window.location.host}/`;
export const CN_HOST = 'https://vtsuru.suki.club/';
export const USER_API_URL = BASE_API_URL + 'user/';
export const ACCOUNT_API_URL = BASE_API_URL + 'account/';
export const BILI_API_URL = BASE_API_URL + 'bili/';
export const SONG_API_URL = BASE_API_URL + 'song-list/';
export const NOTIFACTION_API_URL = BASE_API_URL + 'notification/';
export const QUESTION_API_URL = BASE_API_URL + 'qa/';
export const LOTTERY_API_URL = BASE_API_URL + 'lottery/';
export const HISTORY_API_URL = BASE_API_URL + 'history/';
export const SCHEDULE_API_URL = BASE_API_URL + 'schedule/';
export const VIDEO_COLLECT_API_URL = BASE_API_URL + 'video-collect/';
export const OPEN_LIVE_API_URL = BASE_API_URL + 'open-live/';
export const SONG_REQUEST_API_URL = BASE_API_URL + 'live-request/';
export const QUEUE_API_URL = BASE_API_URL + 'queue/';
export const EVENT_API_URL = BASE_API_URL + 'event/';
export const LIVE_API_URL = BASE_API_URL + 'live/';
export const FEEDBACK_API_URL = BASE_API_URL + 'feedback/';
export const MUSIC_REQUEST_API_URL = BASE_API_URL + 'music-request/';
export const VTSURU_API_URL = BASE_API_URL + 'vtsuru/';
export const POINT_API_URL = BASE_API_URL + 'point/';
export const BILI_AUTH_API_URL = BASE_API_URL + 'bili-auth/';
export const FORUM_API_URL = BASE_API_URL + 'forum/';
export const USER_INDEX_API_URL = BASE_API_URL + 'user-index/';
export const ANALYZE_API_URL = BASE_API_URL + 'analyze/';
export type TemplateMapType = {
[key: string]: {
name: string;
settingName?: string;
component: any;
};
};
export const ScheduleTemplateMap: TemplateMapType = {
'': {
name: '默认',
//settingName: 'Template.Schedule.Default',
component: markRaw(defineAsyncComponent(
() => import('@/views/view/scheduleTemplate/DefaultScheduleTemplate.vue')
))
},
pinky: {
name: '粉粉',
//settingName: 'Template.Schedule.Pinky',
component: markRaw(defineAsyncComponent(
() => import('@/views/view/scheduleTemplate/PinkySchedule.vue')
))
},
kawaii: {
name: '可爱<E58FAF><E788B1>?,
//settingName: 'Template.Schedule.Kawaii',
component: markRaw(defineAsyncComponent(
() => import('@/views/view/scheduleTemplate/KawaiiScheduleTemplate.vue')
))
}
};
export const SongListTemplateMap: TemplateMapType = {
'': {
name: '默认',
//settingName: 'Template.SongList.Default',
component: markRaw(defineAsyncComponent(
() => import('@/views/view/songListTemplate/DefaultSongListTemplate.vue')
))
},
simple: {
name: '简<><E7AE80>?,
//settingName: 'Template.SongList.Simple',
component: markRaw(defineAsyncComponent(
() => import('@/views/view/songListTemplate/SimpleSongListTemplate.vue')
))
},
traditional: {
name: '列表',
settingName: 'Template.SongList.Traditional',
component: markRaw(defineAsyncComponent(
() =>
import('@/views/view/songListTemplate/TraditionalSongListTemplate.vue')
))
}
};
export const IndexTemplateMap: TemplateMapType = {
'': {
name: '默认',
//settingName: 'Template.Index.Default',
component: markRaw(DefaultIndexTemplateVue)
}
};
export const defaultDanmujiCss = `@import url("https://fonts.googleapis.com/css?family=Changa%20One");
@import url("https://fonts.googleapis.com/css?family=Imprima");
/* Transparent background */
yt-live-chat-renderer {
background-color: transparent !important;
}
yt-live-chat-ticker-renderer {
background-color: transparent !important;
box-shadow: none !important;
}
yt-live-chat-author-chip #author-name {
background-color: transparent !important;
}
yt-live-chat-item-list-renderer #item-scroller {
overflow: hidden !important;
}
yt-live-chat-interact-message-renderer #content,
yt-live-chat-text-message-renderer #content,
yt-live-chat-membership-item-renderer #content {
overflow: visible !important;
}
/* Hide header and input */
yt-live-chat-header-renderer,
yt-live-chat-message-input-renderer {
display: none !important;
}
/* Hide unimportant messages */
yt-live-chat-interact-message-renderer[is-deleted],
yt-live-chat-text-message-renderer[is-deleted],
yt-live-chat-membership-item-renderer[is-deleted] {
display: none !important;
}
yt-live-chat-mode-change-message-renderer,
yt-live-chat-viewer-engagement-message-renderer,
yt-live-chat-restricted-participation-renderer {
display: none !important;
}
yt-live-chat-text-message-renderer a,
yt-live-chat-membership-item-renderer a {
text-decoration: none !important;
}
/* Global Setting */
yt-live-chat-renderer {
}
#item-scroller {
}
/* Reduce side padding */
yt-live-chat-interact-message-renderer,
yt-live-chat-text-message-renderer {
padding-left: 4px !important;
padding-right: 4px !important;
}
/* Outlines */
yt-live-chat-renderer * {
text-shadow: -2px -2px #000000, -2px -1px #000000, -2px 0px #000000, -2px 1px #000000, -2px 2px #000000, -1px -2px #000000, -1px -1px #000000, -1px 0px #000000, -1px 1px #000000, -1px 2px #000000, 0px -2px #000000, 0px -1px #000000, 0px 0px #000000, 0px 1px #000000, 0px 2px #000000, 1px -2px #000000, 1px -1px #000000, 1px 0px #000000, 1px 1px #000000, 1px 2px #000000, 2px -2px #000000, 2px -1px #000000, 2px 0px #000000, 2px 1px #000000, 2px 2px #000000;
font-family: "Imprima", "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", SimHei, Arial, sans-serif;
font-size: 18px !important;
line-height: 20px !important;
}
/* Avatars */
yt-live-chat-interact-message-renderer #author-photo,
yt-live-chat-interact-message-renderer #author-photo img,
yt-live-chat-text-message-renderer #author-photo,
yt-live-chat-text-message-renderer #author-photo img,
yt-live-chat-paid-message-renderer #author-photo,
yt-live-chat-paid-message-renderer #author-photo img,
yt-live-chat-membership-item-renderer #author-photo,
yt-live-chat-membership-item-renderer #author-photo img {
width: 24px !important;
height: 24px !important;
border-radius: 24px !important;
margin-right: 6px !important;
}
/* Channel names */
yt-live-chat-interact-message-renderer #content #author-name,
yt-live-chat-text-message-renderer #content #author-name {
}
yt-live-chat-interact-message-renderer #author-name[type="owner"],
yt-live-chat-interact-message-renderer yt-live-chat-author-badge-renderer[type="owner"],
yt-live-chat-text-message-renderer #author-name[type="owner"],
yt-live-chat-text-message-renderer yt-live-chat-author-badge-renderer[type="owner"] {
color: #ffd600 !important;
}
yt-live-chat-interact-message-renderer #author-name[type="moderator"],
yt-live-chat-interact-message-renderer yt-live-chat-author-badge-renderer[type="moderator"],
yt-live-chat-text-message-renderer #author-name[type="moderator"],
yt-live-chat-text-message-renderer yt-live-chat-author-badge-renderer[type="moderator"] {
color: #5e84f1 !important;
}
yt-live-chat-interact-message-renderer #author-name[type="member"],
yt-live-chat-interact-message-renderer yt-live-chat-author-badge-renderer[type="member"],
yt-live-chat-text-message-renderer #author-name[type="member"],
yt-live-chat-text-message-renderer yt-live-chat-author-badge-renderer[type="member"] {
color: #0f9d58 !important;
}
yt-live-chat-interact-message-renderer #author-name,
yt-live-chat-text-message-renderer #author-name {
color: #cccccc !important;
font-family: "Changa One", "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", SimHei, Arial, sans-serif;
font-size: 20px !important;
line-height: 20px !important;
}
/* Show colon */
yt-live-chat-text-message-renderer #author-name::after {
content: ":";
margin-left: 2px;
}
/* Hide badges */
yt-live-chat-interact-message-renderer #chat-badges,
yt-live-chat-text-message-renderer #chat-badges {
vertical-align: text-top !important;
}
img.yt-live-chat-author-badge-renderer, yt-icon.yt-live-chat-author-badge-renderer {
width: 20px;
height: 20px;
}
/* Medal */
yt-live-chat-author-medal-renderer {
display: none;
}
yt-live-chat-author-medal-renderer[is-fan-group] {
display: flex;
}
#medal-name.yt-live-chat-author-medal-renderer {
font-size: 14px !important;
line-height: 14px !important;
}
#medal-level.yt-live-chat-author-medal-renderer {
font-size: 14px !important;
line-height: 14px !important;
}
/* Messages */
yt-live-chat-interact-message-renderer #message,
yt-live-chat-interact-message-renderer #message *,
yt-live-chat-text-message-renderer #message,
yt-live-chat-text-message-renderer #message * {
color: #ffffff !important;
font-family: "Imprima", "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", SimHei, Arial, sans-serif;
font-size: 18px !important;
line-height: 18px !important;
}
yt-live-chat-text-message-renderer #image-and-message {
display: inline !important;
overflow: visible !important;
}
yt-live-chat-text-message-renderer #message {
display: inline !important;
overflow: visible !important;
}
yt-live-chat-text-message-renderer #image-and-message .emoji {
width: auto !important;
height: 48px !important;
}
#image-and-message img[display="block"] {
border-radius: 4px;
}
#image-and-message img[display="inline"] {
position: relative;
top: 3px;
border-radius: 0px;
}
/* Timestamps */
/* Background colors */
body {
overflow: hidden;
background-color: rgba(0, 0, 0, 0);
}
yt-live-chat-text-message-renderer,
yt-live-chat-text-message-renderer[is-highlighted] {
background-color: rgba(204, 204, 204, 0) !important;
}
yt-live-chat-text-message-renderer[author-type="owner"],
yt-live-chat-text-message-renderer[author-type="owner"][is-highlighted] {
background-color: rgba(255, 214, 0, 0) !important;
}
yt-live-chat-text-message-renderer[author-type="moderator"],
yt-live-chat-text-message-renderer[author-type="moderator"][is-highlighted] {
background-color: rgba(94, 132, 241, 0) !important;
}
yt-live-chat-text-message-renderer[author-type="member"],
yt-live-chat-text-message-renderer[author-type="member"][is-highlighted] {
background-color: rgba(15, 157, 88, 0) !important;
}
/* SuperChat/Fan Funding Messages */
yt-live-chat-paid-message-renderer {
margin: 4px 0 !important;
}
yt-live-chat-paid-message-renderer #author-name,
yt-live-chat-paid-message-renderer #author-name *,
yt-live-chat-membership-item-renderer #header-content-inner-column,
yt-live-chat-membership-item-renderer #header-content-inner-column * {
color: #ffffff !important;
font-family: "Changa One", "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", SimHei, Arial, sans-serif;
font-size: 20px !important;
line-height: 20px !important;
}
yt-live-chat-paid-message-renderer #purchase-amount,
yt-live-chat-paid-message-renderer #purchase-amount *,
yt-live-chat-membership-item-renderer #header-subtext,
yt-live-chat-membership-item-renderer #header-subtext * {
color: #ffffff !important;
font-family: "Imprima", "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", SimHei, Arial, sans-serif;
font-size: 18px !important;
line-height: 18px !important;
}
yt-live-chat-paid-message-renderer #content,
yt-live-chat-paid-message-renderer #content * {
color: #ffffff !important;
font-family: "Imprima", "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", SimHei, Arial, sans-serif;
font-size: 18px !important;
line-height: 18px !important;
}
yt-live-chat-membership-item-renderer #card,
yt-live-chat-membership-item-renderer #header {
background-color: #0f9d58 !important;
margin: 4px 0 !important;
}
yt-live-chat-ticker-renderer {
display: none !important;
}
/* SuperChat Ticker */
yt-live-chat-ticker-paid-message-item-renderer,
yt-live-chat-ticker-paid-message-item-renderer *,
yt-live-chat-ticker-sponsor-item-renderer,
yt-live-chat-ticker-sponsor-item-renderer * {
color: #ffffff !important;
font-family: "Imprima", "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", SimHei, Arial, sans-serif;
}
/* Animation */
@keyframes anim {
}
yt-live-chat-interact-message-renderer,
yt-live-chat-text-message-renderer,
yt-live-chat-membership-item-renderer,
yt-live-chat-paid-message-renderer {
animation: anim 0ms;
animation-fill-mode: both;
}
`

View File

@@ -38,7 +38,7 @@
"@vueuse/router": "^13.9.0",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12",
"bilibili-live-ws": "^6.3.1",
"bilibili-live-danmaku": "^0.7.14",
"cropperjs": "^2.0.1",
"crypto-js": "^4.2.0",
"date-fns": "^4.1.0",
@@ -55,7 +55,7 @@
"md5": "^2.3.0",
"mitt": "^3.0.1",
"monaco-editor": "^0.53.0",
"naive-ui": "^2.43.1",
"naive-ui": "2.42.0",
"nanoid": "^5.1.6",
"peerjs": "^1.5.5",
"pinia": "^3.0.3",

View File

@@ -50,6 +50,7 @@ import {
NAlert,
NButton,
NCard,
NDataTable,
NDescriptions,
NDescriptionsItem,
NDivider,
@@ -65,7 +66,6 @@ import {
NQrCode,
NRadioButton,
NRadioGroup,
NScrollbar,
NSpin,
NStatistic,
NTabPane,
@@ -75,7 +75,8 @@ import {
NTooltip,
useMessage,
} from 'naive-ui'
import { computed, nextTick, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
import type { DataTableColumns } from 'naive-ui'
import { computed, h, nextTick, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
import VChart from 'vue-echarts'
import { useAccount } from '@/api/account'
import { useWebFetcher } from '@/store/useWebFetcher'
@@ -238,6 +239,60 @@ const sortedTodayTypes = computed(() => {
.sort(([, countA], [, countB]) => countB - countA)
})
type TodayTypeRow = {
key: string
rank: number
type: string
count: number
}
const todayTypeColumns: DataTableColumns<TodayTypeRow> = [
{
title: '排名',
key: 'rank',
align: 'center',
width: 72,
},
{
title: '类型',
key: 'type',
minWidth: 160,
ellipsis: {
tooltip: true,
},
render: row => h(
NTag,
{
size: 'small',
type: 'info',
bordered: false,
style: 'max-width: 100%; justify-content: flex-start;',
},
{
default: () => row.type,
},
),
},
{
title: '事件数',
key: 'count',
align: 'right',
width: 120,
render: row => row.count.toLocaleString(),
},
]
const todayTypeTableData = computed<TodayTypeRow[]>(() => {
return sortedTodayTypes.value.map(([type, count], index) => ({
key: type,
rank: index + 1,
type,
count,
}))
})
const todayTypeRowKey = (row: TodayTypeRow) => row.key
// Login Status (Computed from original snippet)
const loginStatusString = computed(() => {
switch (loginStatus.value) {
@@ -1039,6 +1094,7 @@ onUnmounted(() => {
<VChart
ref="gaugeChart"
:option="gaugeOption"
:manual-update="true"
autoresize
/>
</div>
@@ -1055,6 +1111,7 @@ onUnmounted(() => {
<VChart
ref="typeDistributionChart"
:option="typeDistributionOption"
:manual-update="true"
autoresize
/>
</div>
@@ -1123,36 +1180,26 @@ onUnmounted(() => {
<NText strong>
类型明细:
</NText>
<NScrollbar style="max-height: 200px; margin-top: 8px;">
<div v-if="sortedTodayTypes.length > 0">
<NFlex
vertical
spacing="small"
>
<NFlex
v-for="[type, count] in sortedTodayTypes"
:key="type"
justify="space-between"
align="center"
>
<NTag
size="small"
:bordered="false"
type="info"
style="max-width: 70%;"
>
<NEllipsis>{{ type }}</NEllipsis>
</NTag>
<NText>{{ count.toLocaleString() }}</NText>
</NFlex>
</NFlex>
</div>
<div style="margin-top: 8px;">
<NDataTable
v-if="todayTypeTableData.length > 0"
:columns="todayTypeColumns"
:data="todayTypeTableData"
:row-key="todayTypeRowKey"
size="small"
:bordered="false"
striped
:pagination="false"
:max-height="220"
single-line
:scrollbar-props="{ size: 6 }"
/>
<NEmpty
v-else
description="今日暂无数据"
size="small"
/>
</NScrollbar>
</div>
</NGi>
</NGrid>
</div>
@@ -1174,6 +1221,7 @@ onUnmounted(() => {
v-if="historicalData.length > 0"
ref="historyChart"
:option="historyOption"
:manual-update="true"
autoresize
/>
<NEmpty

View File

@@ -19,6 +19,7 @@ import { initAll, OnClientUnmounted } from './data/initialize'
import { useDanmakuWindow } from './store/useDanmakuWindow'
// 引入子组件
import WindowBar from './WindowBar.vue'
import { BASE_URL } from '@/data/constants'
// --- 响应式状态 ---
@@ -166,7 +167,7 @@ onMounted(() => {
<template #trigger>
<NA
class="token-get-link"
@click="openUrl('https://vtsuru.suki.club/manage')"
@click="openUrl(`https://${BASE_URL}/manage`)"
>
前往获取
</NA>

View File

@@ -22,6 +22,7 @@ const filterTypeOptions = [
{ label: 'SC', value: 'SC' },
{ label: '舰长', value: 'Guard' },
{ label: '进场', value: 'Enter' },
{ label: '点赞', value: 'Like' },
]
// 分组预设
@@ -211,8 +212,6 @@ const separatorOptions = [
<NGi>
<NFormItem label="背景颜色">
<NColorPicker
v-model:value="danmakuWindow.danmakuWindowSetting.backgroundColor"
:show-alpha="true"
/>
</NFormItem>
</NGi>

View File

@@ -133,6 +133,9 @@ const {
<template v-if="item.type === EventDataTypes.Enter">
<span class="enter-badge">进入了直播间</span>
</template>
<template v-else-if="item.type === EventDataTypes.Like">
<span class="like-badge"> 点赞了</span>
</template>
</div>
<div
v-if="item.type === EventDataTypes.Message && (item?.msg || parsedMessage.length > 0 || item.emoji)"
@@ -450,4 +453,15 @@ const {
word-break: break-all;
white-space: normal;
}
.like-badge {
color: #F56C6C;
font-size: 0.85em;
font-weight: 500;
padding: 1px 6px;
background-color: rgba(245, 108, 108, 0.1);
border-radius: 4px;
margin-left: auto;
white-space: nowrap;
}
</style>

View File

@@ -92,6 +92,7 @@ export function useDanmakuUtils(
case EventDataTypes.SC: return `sc-item ${scColorClass.value}`
case EventDataTypes.Guard: return 'guard-item'
case EventDataTypes.Enter: return 'enter-item'
case EventDataTypes.Like: return 'like-item'
default: return ''
}
})
@@ -171,6 +172,7 @@ export function useDanmakuUtils(
case EventDataTypes.SC: return '【SC】'
case EventDataTypes.Guard: return '【舰长】'
case EventDataTypes.Enter: return '【进场】'
case EventDataTypes.Like: return '【点赞】'
default: return ''
}
})
@@ -202,6 +204,8 @@ export function useDanmakuUtils(
return props.item.msg || '开通了舰长'
case EventDataTypes.Enter:
return '进入了直播间'
case EventDataTypes.Like:
return '点赞了'
default:
return ''
}
@@ -217,6 +221,8 @@ export function useDanmakuUtils(
return guardColor.value // 舰长消息使用舰长颜色
} else if (props.item.type === EventDataTypes.Enter) {
return '#67C23A' // 入场消息绿色
} else if (props.item.type === EventDataTypes.Like) {
return '#F56C6C' // 点赞消息红色
}
return undefined // 普通消息使用默认颜色
})

View File

@@ -143,23 +143,7 @@ export function recordEvent(eventType: string) {
* (需要根据实际接收到的数据结构调整)
*/
export function getEventType(command: any): string {
if (typeof command === 'string') {
try {
command = JSON.parse(command)
} catch (e) {
return 'UNKNOWN_FORMAT'
}
}
if (command && typeof command === 'object') {
// 优先使用 'cmd' 字段 (常见于 Web 或 OpenLive)
if (command.cmd) return command.cmd
// 备选 'command' 字段
if (command.command) return command.command
// 备选 'type' 字段
if (command.type) return command.type
}
return 'UNKNOWN' // 未知类型
return command.cmd
}
/**

View File

@@ -39,7 +39,7 @@ async function sendHeartbeat() {
}
}
function startHeartbeat() {
export function startHeartbeat() {
// 立即发送一次,确保后端在加载后快速收到心跳
void sendHeartbeat()
@@ -50,7 +50,7 @@ function startHeartbeat() {
info('[心跳] 定时器已启动,间隔 2 秒')
}
function stopHeartbeat() {
export function stopHeartbeat() {
if (heartbeatTimer !== null) {
clearInterval(heartbeatTimer)
heartbeatTimer = null
@@ -179,7 +179,7 @@ export async function initAll(isOnBoot: boolean) {
useAutoAction().init()
useBiliFunction().init()
startHeartbeat()
//startHeartbeat()
clientInited.value = true
}
export function OnClientUnmounted() {

View File

@@ -1,4 +1,4 @@
import type { KeepLiveWS } from 'bilibili-live-ws/browser' // 导入 bilibili-live-ws 库
import { LiveWS } from "bilibili-live-danmaku";
// BaseDanmakuClient.ts
import type { EventModel } from '@/api/api-models'
// 导入事件模型和类型枚举
@@ -13,7 +13,7 @@ export default abstract class BaseDanmakuClient {
}
// WebSocket 客户端实例
public client: KeepLiveWS | null
public client: LiveWS | null
// 客户端连接状态
public state: 'padding' | 'connected' | 'connecting' | 'disconnected'
@@ -35,6 +35,7 @@ export default abstract class BaseDanmakuClient {
scDel: ((arg1: EventModel, arg2?: any) => void)[] // 新增: SC 删除事件
all: ((arg1: any) => void)[] // 'all' 事件监听器接收原始消息或特定事件包
follow: ((arg1: EventModel, arg2?: any) => void)[] // 新增: 关注事件
like: ((arg1: EventModel, arg2?: any) => void)[] // 新增: 点赞事件
}
// --- 事件系统 2: 使用原始数据类型 ---
@@ -48,6 +49,7 @@ export default abstract class BaseDanmakuClient {
scDel: ((arg1: any, arg2?: any) => void)[] // 新增: SC 删除事件
all: ((arg1: any) => void)[] // 'all' 事件监听器接收原始消息或特定事件包
follow: ((arg1: any, arg2?: any) => void)[] // 新增: 关注事件
like: ((arg1: any, arg2?: any) => void)[] // 新增: 点赞事件
}
// 创建空的 EventModel 监听器对象
@@ -61,6 +63,7 @@ export default abstract class BaseDanmakuClient {
scDel: [],
all: [],
follow: [], // 初始化 follow 事件
like: [],
}
}
@@ -75,6 +78,7 @@ export default abstract class BaseDanmakuClient {
scDel: [],
all: [],
follow: [], // 初始化 follow 事件
like: [],
}
}
@@ -178,27 +182,27 @@ export default abstract class BaseDanmakuClient {
* @returns Promise<{ success: boolean; message: string }> 连接结果
*/
protected async initClientInner(
chatClient: KeepLiveWS,
chatClient: LiveWS,
): Promise<{ success: boolean, message: string }> {
let isConnected = false // 标记是否连接成功
let isError = false // 标记是否发生错误
let errorMsg = '' // 存储错误信息
// 监听错误事件
chatClient.on('error', (err: any) => {
chatClient.addEventListener('error', (err: any) => {
console.error(`[${this.type}] 客户端发生错误:`, err)
isError = true
errorMsg = err?.message || err?.toString() || '未知错误'
})
// 监听连接成功事件
chatClient.on('live', () => {
chatClient.addEventListener('CONNECT_SUCCESS', () => {
console.log(`[${this.type}] 弹幕客户端连接成功`)
isConnected = true
})
// 监听连接关闭事件
chatClient.on('close', () => {
chatClient.addEventListener('close', () => {
console.log(`[${this.type}] 弹幕客户端连接已关闭`)
if (this.state !== 'disconnected') {
this.state = 'disconnected'
@@ -209,7 +213,7 @@ export default abstract class BaseDanmakuClient {
// 监听原始消息事件 (通用)
// 注意: 子类可能也会监听特定事件名, 这里的 'msg' 是备用或处理未被特定监听器捕获的事件
chatClient.on('msg', (command: any) => this.onRawMessage(command))
chatClient.addEventListener('MESSAGE', (command: any) => this.onRawMessage(command.data))
this.client = chatClient // 保存客户端实例
@@ -301,6 +305,12 @@ export default abstract class BaseDanmakuClient {
* @param rawCommand - 完整的原始消息对象 (可选, any 类型)
*/
public abstract onScDel(comand: any): void
/**
* 处理点赞消息 (子类实现)
* @param data - 原始消息数据部分 (any 类型)
* @param rawCommand - 完整的原始消息对象 (可选, any 类型)
*/
public abstract onLike(comand: any): void
// --- 事件系统 1: on/off (使用 EventModel) ---
public onEvent(eventName: 'danmaku', listener: (arg1: EventModel, arg2?: any) => void): this
@@ -311,6 +321,7 @@ export default abstract class BaseDanmakuClient {
public onEvent(eventName: 'scDel', listener: (arg1: EventModel, arg2?: any) => void): this // 新增
public onEvent(eventName: 'all', listener: (arg1: any) => void): this
public onEvent(eventName: 'follow', listener: (arg1: EventModel, arg2?: any) => void): this // 新增
public onEvent(eventName: 'like', listener: (arg1: EventModel, arg2?: any) => void): this // 新增
public onEvent(eventName: keyof BaseDanmakuClient['eventsAsModel'], listener: (...args: any[]) => void): this {
if (!this.eventsAsModel[eventName]) {
// @ts-ignore
@@ -342,6 +353,7 @@ export default abstract class BaseDanmakuClient {
public on(eventName: 'scDel', listener: (arg1: any, arg2?: any) => void): this // 新增
public on(eventName: 'all', listener: (arg1: any) => void): this
public on(eventName: 'follow', listener: (arg1: any, arg2?: any) => void): this // 新增
public on(eventName: 'like', listener: (arg1: any, arg2?: any) => void): this // 新增
public on(eventName: keyof BaseDanmakuClient['eventsRaw'], listener: (...args: any[]) => void): this {
if (!this.eventsRaw[eventName]) {
// @ts-ignore

View File

@@ -1,8 +1,9 @@
import { KeepLiveWS } from 'bilibili-live-ws/browser'
import { DataEvent, LiveWS, MessageData } from 'bilibili-live-danmaku'
import { EventDataTypes, GuardLevel } from '@/api/api-models'
import { GuidUtils } from '@/Utils'
import { AVATAR_URL } from '../constants'
import BaseDanmakuClient from './BaseDanmakuClient'
import Long from 'long'
export interface DirectClientAuthInfo {
token: string
@@ -28,22 +29,34 @@ export default class DirectClient extends BaseDanmakuClient {
protected async initClient(): Promise<{ success: boolean, message: string }> {
if (this.authInfo) {
const chatClient = new KeepLiveWS(this.authInfo.roomId, {
const chatClient = new LiveWS(this.authInfo.roomId, {
key: this.authInfo.token,
buvid: this.authInfo.buvid,
uid: this.authInfo.tokenUserId,
protover: 3,
})
chatClient.on('live', () => {
chatClient.addEventListener('CONNECT_SUCCESS', () => {
console.log(`[direct] 已连接房间: ${this.authInfo.roomId}`)
})
chatClient.on('DANMU_MSG', data => this.onDanmaku(data))
chatClient.on('SEND_GIFT', data => this.onGift(data))
chatClient.on('GUARD_BUY', data => this.onGuard(data))
chatClient.on('SUPER_CHAT_MESSAGE', data => this.onSC(data))
chatClient.on('INTERACT_WORD', data => this.onEnter(data))
chatClient.on('SUPER_CHAT_MESSAGE_DELETE', data => this.onScDel(data))
chatClient.addEventListener('DANMU_MSG', data => this.onDanmaku(data.data))
chatClient.addEventListener('SEND_GIFT', data => this.onGift(data.data))
chatClient.addEventListener('GUARD_BUY', data => this.onGuard(data.data))
chatClient.addEventListener('SUPER_CHAT_MESSAGE', data => this.onSC(data.data))
//chatClient.addEventListener('INTERACT_WORD', data => this.onEnter(data.data))
chatClient.addEventListener('MESSAGE', data => {
switch (data.data.cmd) {
case 'INTERACT_WORD_V2':
this.onEnter(data.data)
break
case 'LIKE_INFO_V3_CLICK':
this.onLike(data.data)
break
default:
break
}
})
//chatClient.addEventListener('SUPER_CHAT_MESSAGE_DELETE', data => this.onScDel(data))
return super.initClientInner(chatClient)
} else {
@@ -55,7 +68,7 @@ export default class DirectClient extends BaseDanmakuClient {
}
}
public onDanmaku(command: any): void {
public onDanmaku(command: MessageData.DANMU_MSG): void {
const info = command.info
this.eventsRaw?.danmaku?.forEach((d) => {
d(info, command)
@@ -84,7 +97,7 @@ export default class DirectClient extends BaseDanmakuClient {
})
}
public onGift(command: any): void {
public onGift(command: MessageData.SEND_GIFT): void {
const data = command.data
this.eventsRaw?.gift?.forEach((d) => {
d(data, command)
@@ -96,13 +109,13 @@ export default class DirectClient extends BaseDanmakuClient {
uname: data.uname,
uid: data.uid,
msg: data.giftName,
price: data.price / 1000,
price: data.total_coin / 1000,
num: data.num,
time: Date.now(),
guard_level: data.guard_level,
fans_medal_level: data.medal_info.medal_level,
fans_medal_name: data.medal_info.medal_name,
fans_medal_wearing_status: data.medal_info.is_lighted === 1,
fans_medal_level: data.fans_medal?.medal_level,
fans_medal_name: data.fans_medal?.medal_name,
fans_medal_wearing_status: data.fans_medal !== null || data.fans_medal !== undefined,
uface: data.face.replace('http://', 'https://'),
open_id: '',
ouid: GuidUtils.numToGuid(data.uid),
@@ -112,7 +125,7 @@ export default class DirectClient extends BaseDanmakuClient {
})
}
public onSC(command: any): void {
public onSC(command: MessageData.SUPER_CHAT_MESSAGE): void {
const data = command.data
this.eventsRaw?.sc?.forEach((d) => {
d(data, command)
@@ -130,7 +143,7 @@ export default class DirectClient extends BaseDanmakuClient {
guard_level: data.user_info.guard_level,
fans_medal_level: data.medal_info.medal_level,
fans_medal_name: data.medal_info.medal_name,
fans_medal_wearing_status: data.medal_info.is_lighted === 1,
fans_medal_wearing_status: data.medal_info !== null || data.medal_info !== undefined,
uface: data.user_info.face.replace('http://', 'https://'),
open_id: '',
ouid: GuidUtils.numToGuid(data.uid),
@@ -140,7 +153,7 @@ export default class DirectClient extends BaseDanmakuClient {
})
}
public onGuard(command: any): void {
public onGuard(command: MessageData.GUARD_BUY): void {
const data = command.data
this.eventsRaw?.guard?.forEach((d) => {
d(data, command)
@@ -168,9 +181,9 @@ export default class DirectClient extends BaseDanmakuClient {
})
}
public onEnter(command: any): void {
const data = command.data
const msgType = data.msg_type
public onEnter(command: MessageData.INTERACT_WORD_V2): void {
const data = command.decoded
const msgType = data?.msgType
if (msgType === 1) {
this.eventsRaw?.enter?.forEach((d) => {
@@ -180,19 +193,19 @@ export default class DirectClient extends BaseDanmakuClient {
d(
{
type: EventDataTypes.Enter,
uname: data.uname,
uid: data.uid,
uname: data?.uname || '',
uid: this.convertToNumber(data?.uid) || 0,
msg: '',
price: 0,
num: 1,
time: data.timestamp ? data.timestamp * 1000 : Date.now(),
guard_level: data.privilege_type || GuardLevel.None,
fans_medal_level: data.fans_medal?.medal_level || 0,
fans_medal_name: data.fans_medal?.medal_name || '',
fans_medal_wearing_status: data.fans_medal?.is_lighted === 1,
uface: data.face?.replace('http://', 'https://') || (AVATAR_URL + data.uid),
time: data?.timestamp ? this.convertToNumber(data.timestamp) * 1000 : Date.now(),
guard_level: this.convertToNumber(data?.privilegeType) || GuardLevel.None,
fans_medal_level: this.convertToNumber(data?.fansMedal?.medalLevel) || 0,
fans_medal_name: data?.fansMedal?.medalName || '',
fans_medal_wearing_status: data?.fansMedal?.isLighted === 1,
uface: data?.uinfo?.uheadFrame?.frameImg?.replace('http://', 'https://') || (AVATAR_URL + this.convertToNumber(data?.uid)),
open_id: '',
ouid: GuidUtils.numToGuid(data.uid),
ouid: GuidUtils.numToGuid(this.convertToNumber(data?.uid)),
},
command,
)
@@ -205,19 +218,19 @@ export default class DirectClient extends BaseDanmakuClient {
d(
{
type: EventDataTypes.Follow,
uname: data.uname,
uid: data.uid,
uname: data?.uname || '',
uid: this.convertToNumber(data?.uid),
msg: '关注了主播',
price: 0,
num: 1,
time: data.timestamp ? data.timestamp * 1000 : Date.now(),
guard_level: data.privilege_type || GuardLevel.None,
fans_medal_level: data.fans_medal?.medal_level || 0,
fans_medal_name: data.fans_medal?.medal_name || '',
fans_medal_wearing_status: data.fans_medal?.is_lighted === 1,
uface: data.face?.replace('http://', 'https://') || (AVATAR_URL + data.uid),
time: data?.timestamp ? this.convertToNumber(data.timestamp) * 1000 : Date.now(),
guard_level: this.convertToNumber(data?.privilegeType) || GuardLevel.None,
fans_medal_level: this.convertToNumber(data?.fansMedal?.medalLevel) || 0,
fans_medal_name: data?.fansMedal?.medalName || '',
fans_medal_wearing_status: data?.fansMedal?.isLighted === 1,
uface: data?.uinfo?.uheadFrame?.frameImg?.replace('http://', 'https://') || (AVATAR_URL + data?.uid),
open_id: '',
ouid: GuidUtils.numToGuid(data.uid),
ouid: GuidUtils.numToGuid(this.convertToNumber(data?.uid)),
},
command,
)
@@ -225,6 +238,41 @@ export default class DirectClient extends BaseDanmakuClient {
}
}
convertToNumber(value: number | Long | null | undefined): number {
if (value instanceof Long) {
return value.toNumber()
}
return value || 0
}
public onLike(command: any): void {
const data = command.data
this.eventsRaw?.like?.forEach((d) => {
d(data, command)
})
this.eventsAsModel.like?.forEach((d) => {
d(
{
type: EventDataTypes.Like,
uname: data.uname,
uid: data.uid,
msg: '为直播间点赞',
price: 0,
num: 1,
time: Date.now(),
guard_level: 0,
fans_medal_level: data.medal_info?.medal_level ?? 0,
fans_medal_name: data.medal_info?.medal_name ?? '',
fans_medal_wearing_status: data.medal_info?.is_lighted === 1,
uface: data.uface.replace('http://', 'https://'),
open_id: '',
ouid: GuidUtils.numToGuid(data.uid),
},
command,
)
})
}
public onScDel(command: any): void {
const data = command.data
this.eventsRaw?.scDel?.forEach((d) => {

View File

@@ -1,5 +1,5 @@
import type { OpenLiveInfo } from '@/api/api-models'
import { KeepLiveWS } from 'bilibili-live-ws/browser'
import { LiveWS } from 'bilibili-live-danmaku'
import { clearInterval, setInterval } from 'worker-timers'
import { EventDataTypes } from '@/api/api-models'
import { QueryGetAPI, QueryPostAPI } from '@/api/query'
@@ -41,17 +41,35 @@ export default class OpenLiveClient extends BaseDanmakuClient {
protected async initClient(): Promise<{ success: boolean, message: string }> {
const auth = await this.getAuthInfo()
if (auth.data) {
const chatClient = new KeepLiveWS(auth.data.anchor_info.room_id, {
const chatClient = new LiveWS(auth.data.anchor_info.room_id, {
authBody: JSON.parse(auth.data.websocket_info.auth_body),
address: auth.data.websocket_info.wss_link[0],
})
chatClient.on('LIVE_OPEN_PLATFORM_DM', cmd => this.onDanmaku(cmd))
chatClient.on('LIVE_OPEN_PLATFORM_GIFT', cmd => this.onGift(cmd))
chatClient.on('LIVE_OPEN_PLATFORM_GUARD', cmd => this.onGuard(cmd))
chatClient.on('LIVE_OPEN_PLATFORM_SC', cmd => this.onSC(cmd))
chatClient.on('LIVE_OPEN_PLATFORM_LIVE_ROOM_ENTER', cmd => this.onEnter(cmd))
chatClient.on('LIVE_OPEN_PLATFORM_SUPER_CHAT_DEL', cmd => this.onScDel(cmd))
chatClient.on('live', () => {
chatClient.addEventListener('MESSAGE', cmd => {
switch (cmd.data.cmd as string) {
case 'LIVE_OPEN_PLATFORM_DM':
this.onDanmaku(cmd.data)
break
case 'LIVE_OPEN_PLATFORM_GIFT':
this.onGift(cmd.data)
break
case 'LIVE_OPEN_PLATFORM_GUARD':
this.onGuard(cmd.data)
break
case 'LIVE_OPEN_PLATFORM_SC':
this.onSC(cmd.data)
break
case 'LIVE_OPEN_PLATFORM_LIVE_ROOM_ENTER':
this.onEnter(cmd.data)
break
case 'LIVE_OPEN_PLATFORM_SUPER_CHAT_DEL':
this.onScDel(cmd.data)
break
default:
break
}
})
chatClient.addEventListener('CONNECT_SUCCESS', () => {
console.log(
`[${this.type}] 已连接房间: ${auth.data?.anchor_info.room_id}`,
)
@@ -270,6 +288,10 @@ export default class OpenLiveClient extends BaseDanmakuClient {
})
}
public onLike(_command: any): void {
// OpenLiveClient does not support like events
}
public onScDel(command: any): void {
const data = command.data as SCDelInfo
this.eventsRaw.scDel?.forEach((d) => {

View File

@@ -5,6 +5,8 @@ import App from './App.vue'
import { InitVTsuru } from './data/Initializer'
import emitter from './mitt'
import router from './router'
import { isTauri } from './data/constants'
import { startHeartbeat } from './client/data/initialize'
loader.config({
'paths': {
@@ -24,5 +26,8 @@ const app = createApp(App)
app.use(router).use(pinia).mount('#app')
InitVTsuru()
if (isTauri()) {
startHeartbeat();
}
window.$mitt = emitter