mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-06 18:36:55 +08:00
chore: 升级依赖包版本并添加 EventFetcher 功能开关
- 升级核心依赖:@hyperdx/browser、@microsoft/signalr、@tauri-apps 系列插件、@vueuse 系列、vue、vue-router 等至最新版本 - 新增 obs-websocket-js 依赖用于 OBS 集成 - 添加 EventFetcher 功能总开关,支持完全禁用事件收集功能 - 优化弹幕客户端配置界面,增加功能说明和状态提示 - 改进首页布局,添加快速入口导航 - 在客户端布局中新增直播管理菜单项
This commit is contained in:
69
package.json
69
package.json
@@ -11,29 +11,29 @@
|
|||||||
"knip": "knip"
|
"knip": "knip"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hyperdx/browser": "^0.21.2",
|
"@hyperdx/browser": "^0.22.0",
|
||||||
"@hyperdx/cli": "^0.1.0",
|
"@hyperdx/cli": "^0.1.0",
|
||||||
"@microsoft/signalr": "^9.0.6",
|
"@microsoft/signalr": "^10.0.0",
|
||||||
"@microsoft/signalr-protocol-msgpack": "^9.0.6",
|
"@microsoft/signalr-protocol-msgpack": "^10.0.0",
|
||||||
"@mixer/postmessage-rpc": "^1.1.4",
|
"@mixer/postmessage-rpc": "^1.1.4",
|
||||||
"@oneidentity/zstd-js": "^1.0.3",
|
"@oneidentity/zstd-js": "^1.0.3",
|
||||||
"@tauri-apps/api": "^2.8.0",
|
"@tauri-apps/api": "^2.9.0",
|
||||||
"@tauri-apps/plugin-autostart": "^2.5.0",
|
"@tauri-apps/plugin-autostart": "^2.5.1",
|
||||||
"@tauri-apps/plugin-http": "^2.5.2",
|
"@tauri-apps/plugin-http": "^2.5.4",
|
||||||
"@tauri-apps/plugin-log": "^2.7.0",
|
"@tauri-apps/plugin-log": "^2.7.1",
|
||||||
"@tauri-apps/plugin-notification": "^2.3.1",
|
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||||
"@tauri-apps/plugin-opener": "^2.5.0",
|
"@tauri-apps/plugin-opener": "^2.5.2",
|
||||||
"@tauri-apps/plugin-os": "^2.3.1",
|
"@tauri-apps/plugin-os": "^2.3.2",
|
||||||
"@tauri-apps/plugin-process": "^2.3.0",
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
"@tauri-apps/plugin-store": "^2.4.0",
|
"@tauri-apps/plugin-store": "^2.4.1",
|
||||||
"@tauri-apps/plugin-updater": "^2.9.0",
|
"@tauri-apps/plugin-updater": "^2.9.0",
|
||||||
"@types/crypto-js": "^4.2.2",
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/md5": "^2.3.5",
|
"@types/md5": "^2.3.6",
|
||||||
"@vicons/fluent": "^0.13.0",
|
"@vicons/fluent": "^0.13.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
"@vueuse/core": "^13.9.0",
|
"@vueuse/core": "^14.0.0",
|
||||||
"@vueuse/integrations": "^13.9.0",
|
"@vueuse/integrations": "^14.0.0",
|
||||||
"@vueuse/router": "^13.9.0",
|
"@vueuse/router": "^14.0.0",
|
||||||
"@wangeditor/editor": "^5.1.23",
|
"@wangeditor/editor": "^5.1.23",
|
||||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||||
"bilibili-live-danmaku": "^0.7.14",
|
"bilibili-live-danmaku": "^0.7.14",
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"easy-speech": "^2.4.0",
|
"easy-speech": "^2.4.0",
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"fast-xml-parser": "^5.2.5",
|
"fast-xml-parser": "^5.3.2",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"grapheme-splitter": "^1.0.4",
|
"grapheme-splitter": "^1.0.4",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
@@ -52,24 +52,25 @@
|
|||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"md5": "^2.3.0",
|
"md5": "^2.3.0",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"monaco-editor": "^0.53.0",
|
"monaco-editor": "^0.54.0",
|
||||||
"naive-ui": "2.42.0",
|
"naive-ui": "2.43.2",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
|
"obs-websocket-js": "^5.0.7",
|
||||||
"peerjs": "^1.5.5",
|
"peerjs": "^1.5.5",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.4",
|
||||||
"qrcode.vue": "^3.6.0",
|
"qrcode.vue": "^3.6.0",
|
||||||
"unplugin-auto-import": "^20.2.0",
|
"unplugin-auto-import": "^20.2.0",
|
||||||
"unplugin-vue-components": "^29.1.0",
|
"unplugin-vue-components": "^30.0.0",
|
||||||
"unplugin-vue-markdown": "^29.2.0",
|
"unplugin-vue-markdown": "^29.2.0",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"vite": "npm:rolldown-vite@latest",
|
"vite": "npm:rolldown-vite@7.2.5",
|
||||||
"vite-plugin-monaco-editor-nls": "^3.0.1",
|
"vite-plugin-monaco-editor-nls": "^3.0.1",
|
||||||
"vite-svg-loader": "^5.1.0",
|
"vite-svg-loader": "^5.1.0",
|
||||||
"vue": "3.5.22",
|
"vue": "3.5.24",
|
||||||
"vue-echarts": "^8.0.0",
|
"vue-echarts": "^8.0.1",
|
||||||
"vue-img-cutter": "^3.0.7",
|
"vue-img-cutter": "^3.0.7",
|
||||||
"vue-request": "^2.0.4",
|
"vue-request": "^2.0.4",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.6.3",
|
||||||
"vue-toastification": "^1.7.14",
|
"vue-toastification": "^1.7.14",
|
||||||
"vue-turnstile": "^1.0.11",
|
"vue-turnstile": "^1.0.11",
|
||||||
"vue3-aplayer": "^1.7.3",
|
"vue3-aplayer": "^1.7.3",
|
||||||
@@ -79,22 +80,22 @@
|
|||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@antfu/eslint-config": "^5.4.1",
|
"@antfu/eslint-config": "^6.2.0",
|
||||||
"@types/bun": "^1.2.23",
|
"@types/bun": "^1.3.2",
|
||||||
"@types/file-saver": "^2.0.7",
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/jszip": "^3.4.1",
|
"@types/jszip": "^3.4.1",
|
||||||
"@types/uuid": "^11.0.0",
|
"@types/uuid": "^11.0.0",
|
||||||
"@vicons/ionicons5": "^0.13.0",
|
"@vicons/ionicons5": "^0.13.0",
|
||||||
"@vitejs/plugin-vue-jsx": "^5.1.1",
|
"@vitejs/plugin-vue-jsx": "^5.1.1",
|
||||||
"@vue-vine/eslint-config": "^1.1.9",
|
"@vue-vine/eslint-config": "^1.1.11",
|
||||||
"eslint": "^9.36.0",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-oxlint": "^1.19.0",
|
"eslint-plugin-oxlint": "^1.28.0",
|
||||||
"oxlint": "^1.19.0",
|
"oxlint": "^1.28.0",
|
||||||
"rollup-plugin-visualizer": "^6.0.4",
|
"rollup-plugin-visualizer": "^6.0.5",
|
||||||
"stylus": "^0.64.0",
|
"stylus": "^0.64.0",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.3",
|
||||||
"vite-plugin-cdn-import": "^1.0.1",
|
"vite-plugin-cdn-import": "^1.0.1",
|
||||||
"vscode-loc": "git+https://github.com/microsoft/vscode-loc.git",
|
"vscode-loc": "git+https://github.com/microsoft/vscode-loc.git",
|
||||||
"vue-vine": "^1.7.6"
|
"vue-vine": "^1.7.23"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
src/auto-imports.d.ts
vendored
2
src/auto-imports.d.ts
vendored
@@ -208,6 +208,7 @@ declare global {
|
|||||||
const lastDayOfYear: typeof import('date-fns')['lastDayOfYear']
|
const lastDayOfYear: typeof import('date-fns')['lastDayOfYear']
|
||||||
const lightFormat: typeof import('date-fns')['lightFormat']
|
const lightFormat: typeof import('date-fns')['lightFormat']
|
||||||
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
|
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
|
||||||
|
const manualResetRef: typeof import('@vueuse/core')['manualResetRef']
|
||||||
const mapActions: typeof import('pinia')['mapActions']
|
const mapActions: typeof import('pinia')['mapActions']
|
||||||
const mapGetters: typeof import('pinia')['mapGetters']
|
const mapGetters: typeof import('pinia')['mapGetters']
|
||||||
const mapState: typeof import('pinia')['mapState']
|
const mapState: typeof import('pinia')['mapState']
|
||||||
@@ -282,6 +283,7 @@ declare global {
|
|||||||
const refAutoReset: typeof import('@vueuse/core')['refAutoReset']
|
const refAutoReset: typeof import('@vueuse/core')['refAutoReset']
|
||||||
const refDebounced: typeof import('@vueuse/core')['refDebounced']
|
const refDebounced: typeof import('@vueuse/core')['refDebounced']
|
||||||
const refDefault: typeof import('@vueuse/core')['refDefault']
|
const refDefault: typeof import('@vueuse/core')['refDefault']
|
||||||
|
const refManualReset: typeof import('@vueuse/core')['refManualReset']
|
||||||
const refThrottled: typeof import('@vueuse/core')['refThrottled']
|
const refThrottled: typeof import('@vueuse/core')['refThrottled']
|
||||||
const refWithControl: typeof import('@vueuse/core')['refWithControl']
|
const refWithControl: typeof import('@vueuse/core')['refWithControl']
|
||||||
const resolveComponent: typeof import('vue')['resolveComponent']
|
const resolveComponent: typeof import('vue')['resolveComponent']
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ import {
|
|||||||
NRadioGroup,
|
NRadioGroup,
|
||||||
NSpin,
|
NSpin,
|
||||||
NStatistic,
|
NStatistic,
|
||||||
|
NSwitch,
|
||||||
NTabPane,
|
NTabPane,
|
||||||
NTabs,
|
NTabs,
|
||||||
NTag,
|
NTag,
|
||||||
@@ -85,7 +86,7 @@ import { useAccount } from '@/api/account'
|
|||||||
import { useWebFetcher } from '@/store/useWebFetcher'
|
import { useWebFetcher } from '@/store/useWebFetcher'
|
||||||
import { getLoginInfoAsync, getLoginUrlDataAsync } from './data/biliLogin'
|
import { getLoginInfoAsync, getLoginUrlDataAsync } from './data/biliLogin'
|
||||||
import { currentStatistic, getHistoricalStatistics, streamingInfo } from './data/info'
|
import { currentStatistic, getHistoricalStatistics, streamingInfo } from './data/info'
|
||||||
import { callStartDanmakuClient } from './data/initialize'
|
import { callStartDanmakuClient, resetDanmakuClientInitState } from './data/initialize'
|
||||||
import { COOKIE_CLOUD_KEY, useBiliCookie } from './store/useBiliCookie'
|
import { COOKIE_CLOUD_KEY, useBiliCookie } from './store/useBiliCookie'
|
||||||
import { useSettings } from './store/useSettings'
|
import { useSettings } from './store/useSettings'
|
||||||
import { useTauriStore } from './store/useTauriStore'
|
import { useTauriStore } from './store/useTauriStore'
|
||||||
@@ -607,6 +608,30 @@ async function logout() {
|
|||||||
message.info('已退出登录')
|
message.info('已退出登录')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理 EventFetcher 开关切换
|
||||||
|
async function handleToggleEventFetcher(enabled: boolean) {
|
||||||
|
await settings.save()
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
// 启用 EventFetcher
|
||||||
|
message.info('正在启动 EventFetcher...')
|
||||||
|
const result = await callStartDanmakuClient()
|
||||||
|
if (result.success) {
|
||||||
|
message.success('EventFetcher 已启动')
|
||||||
|
} else {
|
||||||
|
message.error(`EventFetcher 启动失败: ${result.message}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 禁用 EventFetcher
|
||||||
|
if (webfetcher.state !== 'disconnected') {
|
||||||
|
webfetcher.Stop()
|
||||||
|
message.info('EventFetcher 已停止')
|
||||||
|
}
|
||||||
|
// 重置弹幕客户端初始化状态,确保重新启用时能正确连接
|
||||||
|
resetDanmakuClientInitState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Watchers ---
|
// --- Watchers ---
|
||||||
watch(() => webfetcher.state, (newState) => {
|
watch(() => webfetcher.state, (newState) => {
|
||||||
if (newState === 'connected') {
|
if (newState === 'connected') {
|
||||||
@@ -727,33 +752,101 @@ onUnmounted(() => {
|
|||||||
embedded
|
embedded
|
||||||
style="width: 100%; max-width: 800px;"
|
style="width: 100%; max-width: 800px;"
|
||||||
>
|
>
|
||||||
<NFlex vertical>
|
<NFlex
|
||||||
<NRadioGroup
|
vertical
|
||||||
v-model:value="settings.settings.useDanmakuClientType"
|
gap="large"
|
||||||
:disabled="webfetcher.state === 'connecting'"
|
>
|
||||||
@update-value="v => onSwitchDanmakuClientMode(v)"
|
<!-- EventFetcher 功能开关 -->
|
||||||
>
|
<div>
|
||||||
<NRadioButton value="openlive">
|
<NFlex
|
||||||
开放平台
|
align="center"
|
||||||
</NRadioButton>
|
justify="space-between"
|
||||||
<NRadioButton
|
style="margin-bottom: 0.5rem;"
|
||||||
value="direct"
|
|
||||||
:disabled="!biliCookie.isCookieValid"
|
|
||||||
>
|
>
|
||||||
<NTooltip v-if="!biliCookie.isCookieValid">
|
<div>
|
||||||
<template #trigger>
|
<NText strong>
|
||||||
直接连接
|
EventFetcher 功能
|
||||||
|
</NText>
|
||||||
|
<NTooltip>
|
||||||
|
<template #trigger>
|
||||||
|
<NIcon
|
||||||
|
:component="HelpCircle"
|
||||||
|
style="margin-left: 0.25rem; cursor: help;"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<div style="max-width: 300px;">
|
||||||
|
<p style="margin: 0 0 8px;">启用后,系统将会:</p>
|
||||||
|
<ul style="padding-left: 18px; margin: 0;">
|
||||||
|
<li>连接到 SignalR 服务器</li>
|
||||||
|
<li>启动弹幕客户端接收直播间消息</li>
|
||||||
|
<li>收集并上传直播间事件数据</li>
|
||||||
|
<li>显示实时统计信息</li>
|
||||||
|
</ul>
|
||||||
|
<p style="margin: 8px 0 0;">关闭后,所有 EventFetcher 相关功能将停止工作。</p>
|
||||||
|
</div>
|
||||||
|
</NTooltip>
|
||||||
|
</div>
|
||||||
|
<NSwitch
|
||||||
|
v-model:value="settings.settings.enableEventFetcher"
|
||||||
|
:disabled="webfetcher.state === 'connecting'"
|
||||||
|
@update:value="handleToggleEventFetcher"
|
||||||
|
>
|
||||||
|
<template #checked>
|
||||||
|
已启用
|
||||||
</template>
|
</template>
|
||||||
请先登录 B 站账号以使用直接连接模式
|
<template #unchecked>
|
||||||
</NTooltip>
|
已禁用
|
||||||
<NText v-else>
|
</template>
|
||||||
直接连接
|
</NSwitch>
|
||||||
</NText>
|
</NFlex>
|
||||||
</NRadioButton>
|
<NAlert
|
||||||
</NRadioGroup>
|
v-if="!settings.settings.enableEventFetcher"
|
||||||
|
type="warning"
|
||||||
|
:bordered="false"
|
||||||
|
style="margin-top: 0.5rem;"
|
||||||
|
>
|
||||||
|
EventFetcher 功能已禁用,直播间事件数据将不会被收集和上传
|
||||||
|
</NAlert>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NDivider style="margin: 0;" />
|
||||||
|
|
||||||
|
<!-- 弹幕客户端模式选择 -->
|
||||||
|
<div>
|
||||||
|
<NText
|
||||||
|
strong
|
||||||
|
style="display: block; margin-bottom: 0.5rem;"
|
||||||
|
>
|
||||||
|
弹幕客户端模式
|
||||||
|
</NText>
|
||||||
|
<NRadioGroup
|
||||||
|
v-model:value="settings.settings.useDanmakuClientType"
|
||||||
|
:disabled="webfetcher.state === 'connecting' || !settings.settings.enableEventFetcher"
|
||||||
|
@update-value="v => onSwitchDanmakuClientMode(v)"
|
||||||
|
>
|
||||||
|
<NRadioButton value="openlive">
|
||||||
|
开放平台
|
||||||
|
</NRadioButton>
|
||||||
|
<NRadioButton
|
||||||
|
value="direct"
|
||||||
|
:disabled="!biliCookie.isCookieValid || !settings.settings.enableEventFetcher"
|
||||||
|
>
|
||||||
|
<NTooltip v-if="!biliCookie.isCookieValid">
|
||||||
|
<template #trigger>
|
||||||
|
直接连接
|
||||||
|
</template>
|
||||||
|
请先登录 B 站账号以使用直接连接模式
|
||||||
|
</NTooltip>
|
||||||
|
<NText v-else>
|
||||||
|
直接连接
|
||||||
|
</NText>
|
||||||
|
</NRadioButton>
|
||||||
|
</NRadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
<NPopconfirm
|
<NPopconfirm
|
||||||
type="info"
|
type="info"
|
||||||
:disabled="webfetcher.state === 'connecting'"
|
:disabled="webfetcher.state === 'connecting' || !settings.settings.enableEventFetcher"
|
||||||
@positive-click="async () => {
|
@positive-click="async () => {
|
||||||
await onSwitchDanmakuClientMode(settings.settings.useDanmakuClientType, true);
|
await onSwitchDanmakuClientMode(settings.settings.useDanmakuClientType, true);
|
||||||
message.success('已重启弹幕服务器');
|
message.success('已重启弹幕服务器');
|
||||||
@@ -762,8 +855,8 @@ onUnmounted(() => {
|
|||||||
<template #trigger>
|
<template #trigger>
|
||||||
<NButton
|
<NButton
|
||||||
type="error"
|
type="error"
|
||||||
style="max-width: 150px;"
|
style="max-width: 180px;"
|
||||||
:disabled="webfetcher.state === 'connecting'"
|
:disabled="webfetcher.state === 'connecting' || !settings.settings.enableEventFetcher"
|
||||||
>
|
>
|
||||||
强制重启弹幕客户端
|
强制重启弹幕客户端
|
||||||
</NButton>
|
</NButton>
|
||||||
@@ -777,6 +870,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<!-- Overall Status & Connection Details -->
|
<!-- Overall Status & Connection Details -->
|
||||||
<NCard
|
<NCard
|
||||||
|
v-if="settings.settings.enableEventFetcher"
|
||||||
title="运行状态 & 连接"
|
title="运行状态 & 连接"
|
||||||
embedded
|
embedded
|
||||||
style="width: 100%; max-width: 800px;"
|
style="width: 100%; max-width: 800px;"
|
||||||
@@ -1148,7 +1242,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<!-- Live Stream Info -->
|
<!-- Live Stream Info -->
|
||||||
<NCard
|
<NCard
|
||||||
v-if="settings.settings.useDanmakuClientType === 'openlive'"
|
v-if="settings.settings.enableEventFetcher && settings.settings.useDanmakuClientType === 'openlive'"
|
||||||
title="直播间信息"
|
title="直播间信息"
|
||||||
embedded
|
embedded
|
||||||
style="width: 100%; max-width: 800px;"
|
style="width: 100%; max-width: 800px;"
|
||||||
@@ -1207,6 +1301,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<!-- Session Statistics -->
|
<!-- Session Statistics -->
|
||||||
<NCard
|
<NCard
|
||||||
|
v-if="settings.settings.enableEventFetcher"
|
||||||
title="会话实时统计"
|
title="会话实时统计"
|
||||||
embedded
|
embedded
|
||||||
style="width: 100%; max-width: 800px;"
|
style="width: 100%; max-width: 800px;"
|
||||||
@@ -1286,6 +1381,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<!-- Daily Statistics -->
|
<!-- Daily Statistics -->
|
||||||
<NCard
|
<NCard
|
||||||
|
v-if="settings.settings.enableEventFetcher"
|
||||||
title="今日统计"
|
title="今日统计"
|
||||||
embedded
|
embedded
|
||||||
style="width: 100%; max-width: 800px;"
|
style="width: 100%; max-width: 800px;"
|
||||||
@@ -1346,6 +1442,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<!-- Historical Statistics -->
|
<!-- Historical Statistics -->
|
||||||
<NCard
|
<NCard
|
||||||
|
v-if="settings.settings.enableEventFetcher"
|
||||||
title="历史事件量 (近30日)"
|
title="历史事件量 (近30日)"
|
||||||
embedded
|
embedded
|
||||||
style="width: 100%; max-width: 800px;"
|
style="width: 100%; max-width: 800px;"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||||
import { useElementSize } from '@vueuse/core'
|
import { useElementSize } from '@vueuse/core'
|
||||||
|
import { Live24Filled, CloudArchive24Filled, FlashAuto24Filled, Mic24Filled, Settings24Filled } from '@vicons/fluent'
|
||||||
import { cookie, useAccount } from '@/api/account'
|
import { cookie, useAccount } from '@/api/account'
|
||||||
import { useWebFetcher } from '@/store/useWebFetcher'
|
import { useWebFetcher } from '@/store/useWebFetcher'
|
||||||
import { roomInfo } from './data/info'
|
import { roomInfo } from './data/info'
|
||||||
@@ -24,22 +25,55 @@ function logout() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<NFlex
|
<NFlex
|
||||||
|
class="client-index-layout"
|
||||||
justify="center"
|
justify="center"
|
||||||
align="center"
|
align="flex-start"
|
||||||
gap="large"
|
gap="large"
|
||||||
|
wrap
|
||||||
>
|
>
|
||||||
<NCard
|
<NCard
|
||||||
title="首页"
|
title="首页"
|
||||||
embedded
|
embedded
|
||||||
size="small"
|
size="small"
|
||||||
|
class="client-index-card"
|
||||||
>
|
>
|
||||||
<div>
|
<NFlex
|
||||||
你好, {{ accountInfo.name }}
|
vertical
|
||||||
</div>
|
gap="small"
|
||||||
|
>
|
||||||
|
<NText>
|
||||||
|
你好, {{ accountInfo.name }}
|
||||||
|
</NText>
|
||||||
|
|
||||||
|
<NDivider style="margin: 8px 0" />
|
||||||
|
|
||||||
|
<NText depth="3" style="font-size: 13px;">
|
||||||
|
快速入口
|
||||||
|
</NText>
|
||||||
|
|
||||||
|
<NFlex
|
||||||
|
vertical
|
||||||
|
gap="8"
|
||||||
|
style="margin-top: 4px;"
|
||||||
|
>
|
||||||
|
<NButton
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
class="client-index-quick-entry-button"
|
||||||
|
@click="$router.push({ name: 'client-live-manage' })"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<NIcon :component="Live24Filled" />
|
||||||
|
</template>
|
||||||
|
进入直播管理
|
||||||
|
</NButton>
|
||||||
|
</NFlex>
|
||||||
|
</NFlex>
|
||||||
</NCard>
|
</NCard>
|
||||||
<NCard
|
<NCard
|
||||||
title="账号"
|
title="账号"
|
||||||
embedded
|
embedded
|
||||||
|
class="client-index-card"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<NFlex
|
<NFlex
|
||||||
@@ -90,7 +124,7 @@ function logout() {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</NCard>
|
</NCard>
|
||||||
<NCard>
|
<NCard class="client-index-card">
|
||||||
<template #header>
|
<template #header>
|
||||||
<NSpace align="center">
|
<NSpace align="center">
|
||||||
直播状态
|
直播状态
|
||||||
@@ -142,3 +176,19 @@ function logout() {
|
|||||||
</NCard>
|
</NCard>
|
||||||
</NFlex>
|
</NFlex>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.client-index-layout {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-index-card {
|
||||||
|
min-width: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-index-quick-entry-button + .client-index-quick-entry-button {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -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, Mic24Filled, Settings24Filled } from '@vicons/fluent'
|
import { Chat24Filled, CloudArchive24Filled, FlashAuto24Filled, Live24Filled, 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'
|
||||||
|
|
||||||
@@ -103,6 +103,12 @@ const menuOptions = computed(() => {
|
|||||||
key: 'fetcher',
|
key: 'fetcher',
|
||||||
icon: () => h(CloudArchive24Filled),
|
icon: () => h(CloudArchive24Filled),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: () =>
|
||||||
|
h(RouterLink, { to: { name: 'client-live-manage' } }, () => '直播管理'),
|
||||||
|
key: 'live-manage',
|
||||||
|
icon: () => h(Live24Filled),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: () =>
|
label: () =>
|
||||||
h(RouterLink, { to: { name: 'client-danmaku-window-manage' } }, () => '弹幕机'),
|
h(RouterLink, { to: { name: 'client-danmaku-window-manage' } }, () => '弹幕机'),
|
||||||
|
|||||||
1636
src/client/ClientLiveManage.vue
Normal file
1636
src/client/ClientLiveManage.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,38 @@ let resetTimeout: number | null = null // 用于重置计数器的超时ID
|
|||||||
const setting = useSettings()
|
const setting = useSettings()
|
||||||
const currentVersion = await getVersion()
|
const currentVersion = await getVersion()
|
||||||
|
|
||||||
|
// 更新检查
|
||||||
|
const isCheckingUpdate = ref(false)
|
||||||
|
const handleCheckUpdate = async () => {
|
||||||
|
isCheckingUpdate.value = true
|
||||||
|
try {
|
||||||
|
const { check } = await import('@tauri-apps/plugin-updater')
|
||||||
|
const update = await check()
|
||||||
|
|
||||||
|
if (update) {
|
||||||
|
window.$message.info(`发现新版本 ${update.version},正在下载更新...`)
|
||||||
|
|
||||||
|
// 下载并安装更新
|
||||||
|
await update.downloadAndInstall()
|
||||||
|
|
||||||
|
window.$message.success('更新已下载,重启应用以完成更新')
|
||||||
|
|
||||||
|
// 询问是否立即重启
|
||||||
|
const { relaunch } = await import('@tauri-apps/plugin-process')
|
||||||
|
setTimeout(() => relaunch(), 2000)
|
||||||
|
} else {
|
||||||
|
window.$message.success('当前已是最新版本')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
console.error('检查更新失败:', err)
|
||||||
|
window.$message.error(`检查更新失败: ${err}`)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
isCheckingUpdate.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
const navOptions: MenuOption[] = [
|
const navOptions: MenuOption[] = [
|
||||||
{ label: '常规', key: 'general' },
|
{ label: '常规', key: 'general' },
|
||||||
@@ -361,6 +393,16 @@ function handleTitleClick() {
|
|||||||
<p>
|
<p>
|
||||||
反馈: 🐧 873260337
|
反馈: 🐧 873260337
|
||||||
</p>
|
</p>
|
||||||
|
<NDivider />
|
||||||
|
<NFlex align="center" justify="space-between">
|
||||||
|
<NText>检查更新</NText>
|
||||||
|
<NButton
|
||||||
|
:loading="isCheckingUpdate"
|
||||||
|
@click="handleCheckUpdate"
|
||||||
|
>
|
||||||
|
检查更新
|
||||||
|
</NButton>
|
||||||
|
</NFlex>
|
||||||
</NCard>
|
</NCard>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
582
src/client/api/live-manage.ts
Normal file
582
src/client/api/live-manage.ts
Normal file
@@ -0,0 +1,582 @@
|
|||||||
|
import { QueryBiliAPI } from '../data/utils'
|
||||||
|
import { useBiliCookie } from '../store/useBiliCookie'
|
||||||
|
import CryptoJS from 'crypto-js'
|
||||||
|
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 直播姬版本信息
|
||||||
|
*/
|
||||||
|
export interface LiveVersionInfo {
|
||||||
|
curr_version: string // 直播姬最新版本号
|
||||||
|
build: number // 直播姬构建号
|
||||||
|
instruction: string // 更新说明(简要)
|
||||||
|
file_size: string // 文件大小(字节)
|
||||||
|
file_md5: string // 安装包文件MD5
|
||||||
|
content: string // HTML格式的更新内容
|
||||||
|
download_url: string // 安装包下载链接
|
||||||
|
hdiffpatch_switch: number // 增量更新开关
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MD5 哈希实现 - 使用 crypto-js
|
||||||
|
*/
|
||||||
|
function md5(str: string): string {
|
||||||
|
return CryptoJS.MD5(str).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP签名函数 - 用于B站API签名
|
||||||
|
* @param params 已包含appkey的参数字典
|
||||||
|
* @param appsec app secret密钥
|
||||||
|
*/
|
||||||
|
function appSign(params: Record<string, any>, appsec: string): Record<string, any> {
|
||||||
|
// 按 key 排序参数
|
||||||
|
const sortedKeys = Object.keys(params).sort()
|
||||||
|
const sortedParams: Record<string, any> = {}
|
||||||
|
sortedKeys.forEach(key => {
|
||||||
|
sortedParams[key] = params[key]
|
||||||
|
})
|
||||||
|
|
||||||
|
// 序列化参数为 key=value&key=value 格式
|
||||||
|
const queryString = sortedKeys.map(key => `${key}=${params[key]}`).join('&')
|
||||||
|
|
||||||
|
// 计算 MD5 签名
|
||||||
|
const signString = queryString + appsec
|
||||||
|
const sign = md5(signString)
|
||||||
|
|
||||||
|
console.log('签名字符串:', signString)
|
||||||
|
console.log('签名结果:', sign)
|
||||||
|
|
||||||
|
// 添加签名
|
||||||
|
sortedParams.sign = sign
|
||||||
|
|
||||||
|
return sortedParams
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前时间戳
|
||||||
|
*/
|
||||||
|
async function getTimestamp(): Promise<number> {
|
||||||
|
try {
|
||||||
|
const resp = await QueryBiliAPI(
|
||||||
|
'https://api.bilibili.com/x/report/click/now',
|
||||||
|
'GET',
|
||||||
|
'',
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
const json = await resp.json()
|
||||||
|
if (json.code === 0 && json.data?.now) {
|
||||||
|
return json.data.now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error('获取服务器时间戳失败,使用本地时间:', err)
|
||||||
|
}
|
||||||
|
return Math.floor(Date.now() / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取直播姬版本号
|
||||||
|
*/
|
||||||
|
export async function getLiveVersion(): Promise<LiveVersionInfo | null> {
|
||||||
|
try {
|
||||||
|
console.log('正在获取直播姬版本号')
|
||||||
|
const appkey = 'aae92bc66f3edfab'
|
||||||
|
const appsec = 'af125a0d5279fd576c1b4418a3e8276d'
|
||||||
|
|
||||||
|
const ts = await getTimestamp()
|
||||||
|
|
||||||
|
// 准备参数并签名
|
||||||
|
const params = appSign({
|
||||||
|
appkey: appkey,
|
||||||
|
system_version: 2,
|
||||||
|
ts,
|
||||||
|
}, appsec)
|
||||||
|
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
|
query.append(key, String(value))
|
||||||
|
})
|
||||||
|
|
||||||
|
const resp = await QueryBiliAPI(
|
||||||
|
`https://api.live.bilibili.com/xlive/app-blink/v1/liveVersionInfo/getHomePageLiveVersion?${query.toString()}`,
|
||||||
|
'GET',
|
||||||
|
'',
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
const json = await resp.json()
|
||||||
|
|
||||||
|
if (json.code === 0 && json.data) {
|
||||||
|
console.log('获取直播姬版本成功:', json.data.curr_version, 'build:', json.data.build)
|
||||||
|
return json.data
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error('获取直播姬版本失败:', err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 直播间管理API
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始直播
|
||||||
|
* @param roomId 直播间ID
|
||||||
|
* @param areaV2 直播分区ID(子分区ID)
|
||||||
|
* @param platform 直播平台 pc | pc_link | android_link
|
||||||
|
* @param version 直播姬版本号(可选)
|
||||||
|
* @param build 直播姬构建号(可选)
|
||||||
|
*/
|
||||||
|
export interface StartLiveParams {
|
||||||
|
roomId: number
|
||||||
|
areaV2: number
|
||||||
|
platform?: 'pc' | 'pc_link' | 'android_link'
|
||||||
|
version?: string
|
||||||
|
build?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartLiveResponse {
|
||||||
|
code: number
|
||||||
|
msg: string
|
||||||
|
message: string
|
||||||
|
data?: {
|
||||||
|
change: number
|
||||||
|
status: string
|
||||||
|
room_type: number
|
||||||
|
rtmp: {
|
||||||
|
addr: string // RTMP推流地址
|
||||||
|
code: string // RTMP推流参数(密钥)
|
||||||
|
new_link: string
|
||||||
|
provider: string
|
||||||
|
}
|
||||||
|
protocols: Array<{
|
||||||
|
protocol: string
|
||||||
|
addr: string
|
||||||
|
code: string
|
||||||
|
new_link: string
|
||||||
|
provider: string
|
||||||
|
}>
|
||||||
|
try_time: string
|
||||||
|
live_key: string
|
||||||
|
sub_session_key: string
|
||||||
|
notice: any
|
||||||
|
qr?: string // 人脸认证二维码
|
||||||
|
need_face_auth: boolean
|
||||||
|
service_source: string
|
||||||
|
rtmp_backup: any
|
||||||
|
up_stream_extra: {
|
||||||
|
isp: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开播错误码
|
||||||
|
export enum StartLiveErrorCode {
|
||||||
|
SUCCESS = 0,
|
||||||
|
NEED_FACE_AUTH = 60024, // 需要人脸认证
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始直播
|
||||||
|
*/
|
||||||
|
export async function startLive(params: StartLiveParams): Promise<StartLiveResponse> {
|
||||||
|
const biliCookieStore = useBiliCookie()
|
||||||
|
const cookie = await biliCookieStore.getBiliCookie()
|
||||||
|
|
||||||
|
console.log('正在开始直播: ', params)
|
||||||
|
|
||||||
|
if (!cookie) {
|
||||||
|
throw new Error('未登录或Cookie无效')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从cookie中提取bili_jct作为csrf
|
||||||
|
const csrfMatch = cookie.match(/bili_jct=([^;]+)/)
|
||||||
|
const csrf = csrfMatch ? csrfMatch[1] : ''
|
||||||
|
|
||||||
|
if (!csrf) {
|
||||||
|
throw new Error('无法获取CSRF令牌')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 准备参数
|
||||||
|
const appkey = 'aae92bc66f3edfab'
|
||||||
|
const appsec = 'af125a0d5279fd576c1b4418a3e8276d'
|
||||||
|
|
||||||
|
// 获取时间戳
|
||||||
|
const ts = await getTimestamp()
|
||||||
|
|
||||||
|
const requestParams: Record<string, any> = {
|
||||||
|
access_key: '', // 留空
|
||||||
|
appkey: appkey,
|
||||||
|
platform: params.platform || 'pc_link',
|
||||||
|
room_id: params.roomId,
|
||||||
|
area_v2: params.areaV2,
|
||||||
|
build: params.build?.toString() || '9343',
|
||||||
|
backup_stream: 0,
|
||||||
|
csrf: csrf,
|
||||||
|
csrf_token: csrf,
|
||||||
|
ts: ts.toString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对参数按字典序排序并签名
|
||||||
|
const signedParams = appSign(requestParams, appsec)
|
||||||
|
|
||||||
|
console.log('已对参数进行签名')
|
||||||
|
console.log('开播请求参数:', signedParams)
|
||||||
|
|
||||||
|
// 将参数作为URL查询字符串,而不是POST body
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
Object.entries(signedParams).forEach(([key, value]) => {
|
||||||
|
query.append(key, String(value))
|
||||||
|
})
|
||||||
|
|
||||||
|
const resp = await QueryBiliAPI(
|
||||||
|
`https://api.live.bilibili.com/room/v1/Room/startLive?${query.toString()}`,
|
||||||
|
'POST',
|
||||||
|
cookie,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
|
||||||
|
const json = await resp.json()
|
||||||
|
console.log('开播响应:', json)
|
||||||
|
return json as StartLiveResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭直播
|
||||||
|
*/
|
||||||
|
export interface StopLiveParams {
|
||||||
|
roomId: number
|
||||||
|
platform?: 'pc' | 'pc_link' | 'android_link'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StopLiveResponse {
|
||||||
|
code: number
|
||||||
|
msg: string
|
||||||
|
message: string
|
||||||
|
data?: {
|
||||||
|
change: number
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭直播
|
||||||
|
*/
|
||||||
|
export async function stopLive(params: StopLiveParams): Promise<StopLiveResponse> {
|
||||||
|
const biliCookieStore = useBiliCookie()
|
||||||
|
const cookie = await biliCookieStore.getBiliCookie()
|
||||||
|
|
||||||
|
if (!cookie) {
|
||||||
|
throw new Error('未登录或Cookie无效')
|
||||||
|
}
|
||||||
|
|
||||||
|
const csrfMatch = cookie.match(/bili_jct=([^;]+)/)
|
||||||
|
const csrf = csrfMatch ? csrfMatch[1] : ''
|
||||||
|
|
||||||
|
if (!csrf) {
|
||||||
|
throw new Error('无法获取CSRF令牌')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new URLSearchParams()
|
||||||
|
formData.append('platform', params.platform || 'pc_link')
|
||||||
|
formData.append('room_id', params.roomId.toString())
|
||||||
|
formData.append('csrf', csrf)
|
||||||
|
|
||||||
|
const resp = await QueryBiliAPI(
|
||||||
|
'https://api.live.bilibili.com/room/v1/Room/stopLive',
|
||||||
|
'POST',
|
||||||
|
cookie,
|
||||||
|
true,
|
||||||
|
formData,
|
||||||
|
)
|
||||||
|
|
||||||
|
const json = await resp.json()
|
||||||
|
return json as StopLiveResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新直播间信息
|
||||||
|
*/
|
||||||
|
export interface UpdateRoomParams {
|
||||||
|
roomId: number
|
||||||
|
title?: string
|
||||||
|
areaId?: number
|
||||||
|
addTag?: string
|
||||||
|
delTag?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateRoomResponse {
|
||||||
|
code: number
|
||||||
|
msg: string
|
||||||
|
message: string
|
||||||
|
data?: {
|
||||||
|
sub_session_key: string
|
||||||
|
audit_info: {
|
||||||
|
audit_title_reason: string
|
||||||
|
audit_title_status: number
|
||||||
|
audit_title?: string
|
||||||
|
update_title: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新直播间信息
|
||||||
|
*/
|
||||||
|
export async function updateRoom(params: UpdateRoomParams): Promise<UpdateRoomResponse> {
|
||||||
|
const biliCookieStore = useBiliCookie()
|
||||||
|
const cookie = await biliCookieStore.getBiliCookie()
|
||||||
|
|
||||||
|
if (!cookie) {
|
||||||
|
throw new Error('未登录或Cookie无效')
|
||||||
|
}
|
||||||
|
|
||||||
|
const csrfMatch = cookie.match(/bili_jct=([^;]+)/)
|
||||||
|
const csrf = csrfMatch ? csrfMatch[1] : ''
|
||||||
|
|
||||||
|
if (!csrf) {
|
||||||
|
throw new Error('无法获取CSRF令牌')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new URLSearchParams()
|
||||||
|
formData.append('room_id', params.roomId.toString())
|
||||||
|
formData.append('csrf', csrf)
|
||||||
|
formData.append('csrf_token', csrf)
|
||||||
|
|
||||||
|
if (params.title !== undefined) {
|
||||||
|
formData.append('title', params.title)
|
||||||
|
}
|
||||||
|
if (params.areaId !== undefined) {
|
||||||
|
formData.append('area_id', params.areaId.toString())
|
||||||
|
}
|
||||||
|
if (params.addTag !== undefined) {
|
||||||
|
formData.append('add_tag', params.addTag)
|
||||||
|
}
|
||||||
|
if (params.delTag !== undefined) {
|
||||||
|
formData.append('del_tag', params.delTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await QueryBiliAPI(
|
||||||
|
'https://api.live.bilibili.com/room/v1/Room/update',
|
||||||
|
'POST',
|
||||||
|
cookie,
|
||||||
|
true,
|
||||||
|
formData,
|
||||||
|
)
|
||||||
|
|
||||||
|
const json = await resp.json()
|
||||||
|
return json as UpdateRoomResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取直播分区列表
|
||||||
|
*/
|
||||||
|
export interface LiveArea {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
parent_id: number
|
||||||
|
parent_name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLiveAreas(): Promise<LiveArea[]> {
|
||||||
|
const resp = await QueryBiliAPI('https://api.live.bilibili.com/room/v1/Area/getList', 'GET', '', false)
|
||||||
|
const json = await resp.json()
|
||||||
|
|
||||||
|
if (json.code === 0 && json.data) {
|
||||||
|
const areas: LiveArea[] = []
|
||||||
|
for (const parent of json.data) {
|
||||||
|
for (const child of parent.list) {
|
||||||
|
areas.push({
|
||||||
|
id: child.id,
|
||||||
|
name: child.name,
|
||||||
|
parent_id: parent.id,
|
||||||
|
parent_name: parent.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return areas
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('获取直播分区失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateRoomNewsParams {
|
||||||
|
roomId: number
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateRoomNewsResponse {
|
||||||
|
code: number
|
||||||
|
message: string
|
||||||
|
data: any
|
||||||
|
ttl?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRoomNews(params: UpdateRoomNewsParams): Promise<UpdateRoomNewsResponse> {
|
||||||
|
const biliCookieStore = useBiliCookie()
|
||||||
|
const cookie = await biliCookieStore.getBiliCookie()
|
||||||
|
|
||||||
|
if (!cookie) {
|
||||||
|
throw new Error('未登录或Cookie无效')
|
||||||
|
}
|
||||||
|
|
||||||
|
const csrfMatch = cookie.match(/bili_jct=([^;]+)/)
|
||||||
|
const csrf = csrfMatch ? csrfMatch[1] : ''
|
||||||
|
|
||||||
|
const uidMatch = cookie.match(/DedeUserID=([^;]+)/)
|
||||||
|
const uid = uidMatch ? uidMatch[1] : ''
|
||||||
|
|
||||||
|
if (!csrf || !uid) {
|
||||||
|
throw new Error('无法获取CSRF令牌或用户ID')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new URLSearchParams()
|
||||||
|
formData.append('room_id', params.roomId.toString())
|
||||||
|
formData.append('uid', uid)
|
||||||
|
formData.append('content', params.content ?? '')
|
||||||
|
formData.append('csrf', csrf)
|
||||||
|
formData.append('csrf_token', csrf)
|
||||||
|
|
||||||
|
const resp = await QueryBiliAPI(
|
||||||
|
'https://api.live.bilibili.com/xlive/app-blink/v1/index/updateRoomNews',
|
||||||
|
'POST',
|
||||||
|
cookie,
|
||||||
|
true,
|
||||||
|
formData,
|
||||||
|
)
|
||||||
|
|
||||||
|
const json = await resp.json()
|
||||||
|
return json as UpdateRoomNewsResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadCoverResult {
|
||||||
|
location: string
|
||||||
|
etag?: string
|
||||||
|
image_url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadCoverResponse {
|
||||||
|
code: number
|
||||||
|
message: string
|
||||||
|
data?: UploadCoverResult
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadCover(file: File): Promise<UploadCoverResponse> {
|
||||||
|
const biliCookieStore = useBiliCookie()
|
||||||
|
const cookie = await biliCookieStore.getBiliCookie()
|
||||||
|
|
||||||
|
if (!cookie) {
|
||||||
|
throw new Error('未登录或Cookie无效')
|
||||||
|
}
|
||||||
|
|
||||||
|
const csrfMatch = cookie.match(/bili_jct=([^;]+)/)
|
||||||
|
const csrf = csrfMatch ? csrfMatch[1] : ''
|
||||||
|
|
||||||
|
if (!csrf) {
|
||||||
|
throw new Error('无法获取CSRF令牌')
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiUrl = 'https://api.bilibili.com/x/upload/web/image'
|
||||||
|
const boundary = '----WebKitFormBoundary' + Math.random().toString(16).slice(2)
|
||||||
|
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
|
||||||
|
const parts: string[] = []
|
||||||
|
parts.push(
|
||||||
|
`--${boundary}\r\n` +
|
||||||
|
'Content-Disposition: form-data; name="bucket"\r\n\r\n' +
|
||||||
|
'live\r\n',
|
||||||
|
)
|
||||||
|
parts.push(
|
||||||
|
`--${boundary}\r\n` +
|
||||||
|
'Content-Disposition: form-data; name="dir"\r\n\r\n' +
|
||||||
|
'new_room_cover\r\n',
|
||||||
|
)
|
||||||
|
parts.push(
|
||||||
|
`--${boundary}\r\n` +
|
||||||
|
'Content-Disposition: form-data; name="csrf"\r\n\r\n' +
|
||||||
|
`${csrf}\r\n`,
|
||||||
|
)
|
||||||
|
parts.push(
|
||||||
|
`--${boundary}\r\n` +
|
||||||
|
`Content-Disposition: form-data; name="file"; filename="${file.name || 'blob'}"\r\n` +
|
||||||
|
`Content-Type: ${file.type || 'image/jpeg'}\r\n\r\n`,
|
||||||
|
)
|
||||||
|
|
||||||
|
const headBytes = encoder.encode(parts.join(''))
|
||||||
|
const fileBytes = new Uint8Array(await file.arrayBuffer())
|
||||||
|
const tailBytes = encoder.encode(`\r\n--${boundary}--\r\n`)
|
||||||
|
|
||||||
|
const body = new Uint8Array(headBytes.length + fileBytes.length + tailBytes.length)
|
||||||
|
body.set(headBytes, 0)
|
||||||
|
body.set(fileBytes, headBytes.length)
|
||||||
|
body.set(tailBytes, headBytes.length + fileBytes.length)
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0',
|
||||||
|
'Origin': 'https://www.bilibili.com',
|
||||||
|
'Referer': 'https://live.bilibili.com/',
|
||||||
|
'Cookie': cookie,
|
||||||
|
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await tauriFetch(apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(`上传封面失败: HTTP ${resp.status} ${resp.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await resp.json()
|
||||||
|
return json as UploadCoverResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCoverResponse {
|
||||||
|
code: number
|
||||||
|
message: string
|
||||||
|
data?: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCover(coverUrl: string): Promise<UpdateCoverResponse> {
|
||||||
|
const biliCookieStore = useBiliCookie()
|
||||||
|
const cookie = await biliCookieStore.getBiliCookie()
|
||||||
|
|
||||||
|
if (!cookie) {
|
||||||
|
throw new Error('未登录或Cookie无效')
|
||||||
|
}
|
||||||
|
|
||||||
|
const csrfMatch = cookie.match(/bili_jct=([^;]+)/)
|
||||||
|
const csrf = csrfMatch ? csrfMatch[1] : ''
|
||||||
|
|
||||||
|
if (!csrf) {
|
||||||
|
throw new Error('无法获取CSRF令牌')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new URLSearchParams()
|
||||||
|
formData.append('platform', 'web')
|
||||||
|
formData.append('mobi_app', 'web')
|
||||||
|
formData.append('build', '1')
|
||||||
|
formData.append('cover', coverUrl)
|
||||||
|
formData.append('coverVertical', '')
|
||||||
|
formData.append('liveDirectionType', '1')
|
||||||
|
formData.append('csrf', csrf)
|
||||||
|
formData.append('csrf_token', csrf)
|
||||||
|
|
||||||
|
const resp = await QueryBiliAPI(
|
||||||
|
'https://api.live.bilibili.com/xlive/app-blink/v1/preLive/UpdatePreLiveInfo',
|
||||||
|
'POST',
|
||||||
|
cookie,
|
||||||
|
true,
|
||||||
|
formData,
|
||||||
|
)
|
||||||
|
|
||||||
|
const json = await resp.json()
|
||||||
|
return json as UpdateCoverResponse
|
||||||
|
}
|
||||||
@@ -14,7 +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, ref } from 'vue'
|
import { h, ref, watch } 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'
|
||||||
@@ -23,8 +23,9 @@ import { useBiliCookie } from '../store/useBiliCookie'
|
|||||||
import { useBiliFunction } from '../store/useBiliFunction'
|
import { useBiliFunction } from '../store/useBiliFunction'
|
||||||
import { useDanmakuWindow } from '../store/useDanmakuWindow'
|
import { useDanmakuWindow } from '../store/useDanmakuWindow'
|
||||||
import { useSettings } from '../store/useSettings'
|
import { useSettings } from '../store/useSettings'
|
||||||
import { initInfo } from './info'
|
import { initInfo, roomInfo } from './info'
|
||||||
import { getBuvid, getRoomKey } from './utils'
|
import { getBuvid, getRoomKey } from './utils'
|
||||||
|
import { useTauriStore } from '../store/useTauriStore'
|
||||||
|
|
||||||
const accountInfo = useAccount()
|
const accountInfo = useAccount()
|
||||||
|
|
||||||
@@ -35,6 +36,14 @@ let heartbeatTimer: number | null = null
|
|||||||
let updateCheckTimer: number | null = null
|
let updateCheckTimer: number | null = null
|
||||||
let updateNotificationRef: any = null
|
let updateNotificationRef: any = null
|
||||||
|
|
||||||
|
// interface RtmpRelayState {
|
||||||
|
// roomId: number
|
||||||
|
// targetRtmpUrl: string
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const RTMP_RELAY_STATE_KEY = 'webfetcher.rtmpRelay'
|
||||||
|
// let hasTriedAutoResumeRtmp = false
|
||||||
|
|
||||||
async function sendHeartbeat() {
|
async function sendHeartbeat() {
|
||||||
try {
|
try {
|
||||||
await invoke('heartbeat', undefined, {
|
await invoke('heartbeat', undefined, {
|
||||||
@@ -45,6 +54,49 @@ async function sendHeartbeat() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// async function tryAutoResumeRtmpRelay() {
|
||||||
|
// if (hasTriedAutoResumeRtmp) return
|
||||||
|
//
|
||||||
|
// const store = useTauriStore()
|
||||||
|
// const saved = await store.get<RtmpRelayState | null>(RTMP_RELAY_STATE_KEY)
|
||||||
|
// if (!saved || !saved.roomId || !saved.targetRtmpUrl) {
|
||||||
|
// hasTriedAutoResumeRtmp = true
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// const room = roomInfo.value
|
||||||
|
// if (!room || room.live_status !== 1) {
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// if (room.room_id !== saved.roomId) {
|
||||||
|
// hasTriedAutoResumeRtmp = true
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// try {
|
||||||
|
// // 如果已经在进行 RTMP 转发,则不再重复启动
|
||||||
|
// try {
|
||||||
|
// const status = await invoke<{ is_relaying: boolean }>('get_rtmp_relay_status')
|
||||||
|
// if (status?.is_relaying) {
|
||||||
|
// info('[RTMP] 已在转发中,跳过自动恢复')
|
||||||
|
// hasTriedAutoResumeRtmp = true
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// catch (error) {
|
||||||
|
// warn(`[RTMP] 获取 RTMP 转发状态失败: ${error}`)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// await invoke('start_rtmp_relay', { targetUrl: saved.targetRtmpUrl })
|
||||||
|
// info('[RTMP] 检测到正在开播,已自动恢复 RTMP 转发')
|
||||||
|
// } catch (error) {
|
||||||
|
// warn(`[RTMP] 自动恢复 RTMP 转发失败: ${error}`)
|
||||||
|
// } finally {
|
||||||
|
// hasTriedAutoResumeRtmp = true
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
export function startHeartbeat() {
|
export function startHeartbeat() {
|
||||||
// 立即发送一次,确保后端在加载后快速收到心跳
|
// 立即发送一次,确保后端在加载后快速收到心跳
|
||||||
void sendHeartbeat()
|
void sendHeartbeat()
|
||||||
@@ -379,6 +431,12 @@ export async function initAll(isOnBoot: boolean) {
|
|||||||
startUpdateCheck()
|
startUpdateCheck()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// void tryAutoResumeRtmpRelay()
|
||||||
|
|
||||||
|
// watch(roomInfo, () => {
|
||||||
|
// void tryAutoResumeRtmpRelay()
|
||||||
|
// })
|
||||||
|
|
||||||
clientInited.value = true
|
clientInited.value = true
|
||||||
clientInitStage.value = '启动完成'
|
clientInitStage.value = '启动完成'
|
||||||
}
|
}
|
||||||
@@ -400,6 +458,13 @@ export async function checkUpdate() {
|
|||||||
|
|
||||||
export const isInitedDanmakuClient = ref(false)
|
export const isInitedDanmakuClient = ref(false)
|
||||||
export const isInitingDanmakuClient = ref(false)
|
export const isInitingDanmakuClient = ref(false)
|
||||||
|
|
||||||
|
// 重置弹幕客户端初始化状态
|
||||||
|
export function resetDanmakuClientInitState() {
|
||||||
|
isInitedDanmakuClient.value = false
|
||||||
|
isInitingDanmakuClient.value = false
|
||||||
|
info('弹幕客户端初始化状态已重置')
|
||||||
|
}
|
||||||
export async function initDanmakuClient() {
|
export async function initDanmakuClient() {
|
||||||
const biliCookie = useBiliCookie()
|
const biliCookie = useBiliCookie()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
@@ -407,6 +472,13 @@ export async function initDanmakuClient() {
|
|||||||
info('弹幕客户端已初始化, 跳过初始化')
|
info('弹幕客户端已初始化, 跳过初始化')
|
||||||
return { success: true, message: '' }
|
return { success: true, message: '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否启用 EventFetcher
|
||||||
|
if (!settings.settings.enableEventFetcher) {
|
||||||
|
info('EventFetcher 功能已禁用, 跳过弹幕客户端初始化')
|
||||||
|
return { success: true, message: 'EventFetcher 已禁用' }
|
||||||
|
}
|
||||||
|
|
||||||
isInitingDanmakuClient.value = true
|
isInitingDanmakuClient.value = true
|
||||||
console.log(settings.settings)
|
console.log(settings.settings)
|
||||||
let result = { success: false, message: '' }
|
let result = { success: false, message: '' }
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { OPEN_LIVE_API_URL } from '@/data/constants'
|
|||||||
import { useBiliCookie } from '../store/useBiliCookie'
|
import { useBiliCookie } from '../store/useBiliCookie'
|
||||||
import { useBiliFunction } from '../store/useBiliFunction'
|
import { useBiliFunction } from '../store/useBiliFunction'
|
||||||
|
|
||||||
export async function QueryBiliAPI(url: string, method: string = 'GET', cookie: string = '', useCookie: boolean = true) {
|
export async function QueryBiliAPI(url: string, method: string = 'GET', cookie: string = '', useCookie: boolean = true, body?: string | URLSearchParams) {
|
||||||
const u = new URL(url)
|
const u = new URL(url)
|
||||||
console.log(`调用bilibili api: ${url}`)
|
console.log(`调用bilibili api: ${url}`)
|
||||||
const userAgents = [
|
const userAgents = [
|
||||||
@@ -17,13 +17,21 @@ export async function QueryBiliAPI(url: string, method: string = 'GET', cookie:
|
|||||||
]
|
]
|
||||||
const randomUserAgent = userAgents[Math.floor(Math.random() * userAgents.length)]
|
const randomUserAgent = userAgents[Math.floor(Math.random() * userAgents.length)]
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'User-Agent': randomUserAgent,
|
||||||
|
'Origin': 'https://www.bilibili.com',
|
||||||
|
'Referer': 'https://live.bilibili.com/',
|
||||||
|
'Cookie': useCookie ? (cookie || (await useBiliCookie().getBiliCookie()) || '') : '',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
headers['Content-Type'] = 'application/x-www-form-urlencoded'
|
||||||
|
}
|
||||||
|
|
||||||
return fetch(url, {
|
return fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers,
|
||||||
'User-Agent': randomUserAgent,
|
body: body instanceof URLSearchParams ? body.toString() : body,
|
||||||
'Origin': 'https://www.bilibili.com',
|
|
||||||
'Cookie': useCookie ? (cookie || (await useBiliCookie().getBiliCookie()) || '') : '',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
551
src/client/store/useOBSStore.ts
Normal file
551
src/client/store/useOBSStore.ts
Normal file
@@ -0,0 +1,551 @@
|
|||||||
|
import { acceptHMRUpdate, defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import OBSWebSocket from 'obs-websocket-js'
|
||||||
|
import { useTauriStore } from './useTauriStore'
|
||||||
|
|
||||||
|
// OBS配置接口
|
||||||
|
export interface ObsConfigState {
|
||||||
|
address: string
|
||||||
|
password?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 场景配置接口
|
||||||
|
export interface ObsSceneConfig {
|
||||||
|
startScene?: string // 开播场景
|
||||||
|
stopScene?: string // 下播场景
|
||||||
|
waitingScene?: string // 等待场景
|
||||||
|
autoSwitchEnabled: boolean // 是否启用自动切换
|
||||||
|
autoToggleStream: boolean // 是否在开播下播后自动切换OBS推流状态
|
||||||
|
}
|
||||||
|
|
||||||
|
// OBS统计信息接口
|
||||||
|
export interface ObsStats {
|
||||||
|
cpuUsage: number | null
|
||||||
|
memoryUsage: number | null
|
||||||
|
fps: number | null
|
||||||
|
averageRenderTimeMs: number | null
|
||||||
|
renderSkippedFrames: number | null
|
||||||
|
renderTotalFrames: number | null
|
||||||
|
outputSkippedFrames: number | null
|
||||||
|
outputTotalFrames: number | null
|
||||||
|
bitrateKbps: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useOBSStore = defineStore('obs', () => {
|
||||||
|
// 基础配置
|
||||||
|
const OBS_CONFIG_KEY = 'webfetcher.obsConfig'
|
||||||
|
const OBS_SCENE_CONFIG_KEY = 'webfetcher.obsSceneConfig'
|
||||||
|
const tauriStore = useTauriStore()
|
||||||
|
|
||||||
|
// 连接状态
|
||||||
|
const obsAddress = ref('ws://127.0.0.1:4455')
|
||||||
|
const obsPassword = ref('')
|
||||||
|
const obsConnected = ref(false)
|
||||||
|
const obsConnecting = ref(false)
|
||||||
|
const obsError = ref('')
|
||||||
|
const obsAutoReconnect = ref(false)
|
||||||
|
|
||||||
|
// 推流状态
|
||||||
|
const obsStreamActive = ref(false)
|
||||||
|
const obsStreamReconnecting = ref(false)
|
||||||
|
const isTogglingObsStream = ref(false)
|
||||||
|
|
||||||
|
// 统计信息
|
||||||
|
const obsStats = ref<ObsStats>({
|
||||||
|
cpuUsage: null,
|
||||||
|
memoryUsage: null,
|
||||||
|
fps: null,
|
||||||
|
averageRenderTimeMs: null,
|
||||||
|
renderSkippedFrames: null,
|
||||||
|
renderTotalFrames: null,
|
||||||
|
outputSkippedFrames: null,
|
||||||
|
outputTotalFrames: null,
|
||||||
|
bitrateKbps: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 场景控制
|
||||||
|
const obsScenes = ref<string[]>([])
|
||||||
|
const currentObsScene = ref('')
|
||||||
|
const isSwitchingScene = ref(false)
|
||||||
|
const obsSceneError = ref('')
|
||||||
|
const obsSceneConfig = ref<ObsSceneConfig>({
|
||||||
|
autoSwitchEnabled: false,
|
||||||
|
autoToggleStream: true // 默认开启
|
||||||
|
})
|
||||||
|
|
||||||
|
// OBS实例和定时器
|
||||||
|
let obs: OBSWebSocket | null = null
|
||||||
|
let obsStatsTimer: number | null = null
|
||||||
|
let obsReconnectTimer: number | null = null
|
||||||
|
let lastObsBytes = 0
|
||||||
|
let lastObsBytesTimestamp = 0
|
||||||
|
|
||||||
|
// 初始化OBS实例
|
||||||
|
function ensureObsInstance() {
|
||||||
|
if (!obs) {
|
||||||
|
obs = new OBSWebSocket()
|
||||||
|
obs.on('ConnectionClosed', () => {
|
||||||
|
obsConnected.value = false
|
||||||
|
obsStreamActive.value = false
|
||||||
|
stopObsStatsLoop()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新OBS统计信息
|
||||||
|
async function updateObsStats() {
|
||||||
|
if (!obs || !obsConnected.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats: any = await obs.call('GetStats')
|
||||||
|
obsStats.value.cpuUsage = typeof stats.cpuUsage === 'number' ? stats.cpuUsage : null
|
||||||
|
obsStats.value.memoryUsage = typeof stats.memoryUsage === 'number' ? stats.memoryUsage : null
|
||||||
|
obsStats.value.fps = typeof stats.activeFps === 'number' ? stats.activeFps : null
|
||||||
|
obsStats.value.averageRenderTimeMs = typeof stats.averageFrameRenderTime === 'number' ? stats.averageFrameRenderTime : null
|
||||||
|
obsStats.value.renderSkippedFrames = typeof stats.renderSkippedFrames === 'number' ? stats.renderSkippedFrames : null
|
||||||
|
obsStats.value.renderTotalFrames = typeof stats.renderTotalFrames === 'number' ? stats.renderTotalFrames : null
|
||||||
|
obsStats.value.outputSkippedFrames = typeof stats.outputSkippedFrames === 'number' ? stats.outputSkippedFrames : null
|
||||||
|
obsStats.value.outputTotalFrames = typeof stats.outputTotalFrames === 'number' ? stats.outputTotalFrames : null
|
||||||
|
|
||||||
|
const streamStatus: any = await obs.call('GetStreamStatus')
|
||||||
|
obsStreamActive.value = !!streamStatus.outputActive
|
||||||
|
obsStreamReconnecting.value = !!streamStatus.outputReconnecting
|
||||||
|
|
||||||
|
// 计算码率
|
||||||
|
const now = Date.now()
|
||||||
|
const bytes = typeof streamStatus.outputBytes === 'number' ? streamStatus.outputBytes : 0
|
||||||
|
if (lastObsBytesTimestamp && now > lastObsBytesTimestamp && bytes >= lastObsBytes) {
|
||||||
|
const deltaBytes = bytes - lastObsBytes
|
||||||
|
const deltaSeconds = (now - lastObsBytesTimestamp) / 1000
|
||||||
|
if (deltaSeconds > 0) {
|
||||||
|
const kbps = (deltaBytes * 8) / 1000 / deltaSeconds
|
||||||
|
obsStats.value.bitrateKbps = Number.isFinite(kbps) ? kbps : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastObsBytes = bytes
|
||||||
|
lastObsBytesTimestamp = now
|
||||||
|
|
||||||
|
// 获取当前场景
|
||||||
|
await updateCurrentScene()
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error('获取 OBS 统计失败:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动统计循环
|
||||||
|
function startObsStatsLoop() {
|
||||||
|
if (obsStatsTimer !== null) return
|
||||||
|
obsStatsTimer = window.setInterval(() => {
|
||||||
|
void updateObsStats()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止统计循环
|
||||||
|
function stopObsStatsLoop() {
|
||||||
|
if (obsStatsTimer !== null) {
|
||||||
|
clearInterval(obsStatsTimer)
|
||||||
|
obsStatsTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动自动重连循环
|
||||||
|
function startObsAutoReconnectLoop() {
|
||||||
|
if (obsReconnectTimer !== null) return
|
||||||
|
|
||||||
|
// 如果当前条件满足,立即尝试连接一次
|
||||||
|
if (obsAutoReconnect.value &&
|
||||||
|
obsAddress.value &&
|
||||||
|
obsPassword.value &&
|
||||||
|
!obsConnected.value &&
|
||||||
|
!obsConnecting.value) {
|
||||||
|
void handleObsConnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动定时重连循环
|
||||||
|
obsReconnectTimer = window.setInterval(() => {
|
||||||
|
if (!obsAutoReconnect.value) return
|
||||||
|
if (!obsAddress.value) return
|
||||||
|
if (obsConnected.value || obsConnecting.value) return
|
||||||
|
// 确保地址和密码都已设置才尝试连接
|
||||||
|
if (!obsPassword.value) return
|
||||||
|
void handleObsConnect()
|
||||||
|
}, 10000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止自动重连循环
|
||||||
|
function stopObsAutoReconnectLoop() {
|
||||||
|
if (obsReconnectTimer !== null) {
|
||||||
|
clearInterval(obsReconnectTimer)
|
||||||
|
obsReconnectTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 连接OBS
|
||||||
|
async function handleObsConnect() {
|
||||||
|
console.log('handleObsConnect called')
|
||||||
|
console.log('obsConnected:', obsConnected.value, 'obsConnecting:', obsConnecting.value)
|
||||||
|
|
||||||
|
if (obsConnected.value || obsConnecting.value) {
|
||||||
|
console.log('Early return: already connected or connecting')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Starting OBS connection process...')
|
||||||
|
obsError.value = ''
|
||||||
|
obsConnecting.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureObsInstance()
|
||||||
|
if (!obs) {
|
||||||
|
throw new Error('OBS 实例未初始化')
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = obsAddress.value || 'ws://127.0.0.1:4455'
|
||||||
|
const password = obsPassword.value || undefined
|
||||||
|
|
||||||
|
await obs.connect(address, password, {
|
||||||
|
rpcVersion: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
obsConnected.value = true
|
||||||
|
obsConnecting.value = false
|
||||||
|
obsAutoReconnect.value = true
|
||||||
|
startObsAutoReconnectLoop()
|
||||||
|
|
||||||
|
// 保存配置
|
||||||
|
try {
|
||||||
|
await tauriStore.set(OBS_CONFIG_KEY, {
|
||||||
|
address,
|
||||||
|
password: obsPassword.value || undefined,
|
||||||
|
} as ObsConfigState)
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error('保存 OBS 配置失败:', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
startObsStatsLoop()
|
||||||
|
void updateObsStats()
|
||||||
|
|
||||||
|
// 连接成功后获取场景列表
|
||||||
|
void fetchObsScenes()
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
console.error('连接 OBS 失败:', err)
|
||||||
|
obsError.value = err?.message || String(err)
|
||||||
|
obsConnected.value = false
|
||||||
|
obsConnecting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 断开OBS连接
|
||||||
|
async function handleObsDisconnect() {
|
||||||
|
obsError.value = ''
|
||||||
|
obsAutoReconnect.value = false
|
||||||
|
stopObsStatsLoop()
|
||||||
|
stopObsAutoReconnectLoop()
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (obs) {
|
||||||
|
await obs.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error('断开 OBS 失败:', err)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
obsConnected.value = false
|
||||||
|
obsStreamActive.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换推流状态
|
||||||
|
async function handleObsToggleStream() {
|
||||||
|
if (!obs || !obsConnected.value) {
|
||||||
|
window.$message.error('请先连接 OBS')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
isTogglingObsStream.value = true
|
||||||
|
const result: any = await obs.call('ToggleStream')
|
||||||
|
if (typeof result?.outputActive === 'boolean') {
|
||||||
|
obsStreamActive.value = result.outputActive
|
||||||
|
}
|
||||||
|
window.$message.success(obsStreamActive.value ? '已开始 OBS 推流' : '已停止 OBS 推流')
|
||||||
|
void updateObsStats()
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
console.error('切换 OBS 推流状态失败:', err)
|
||||||
|
window.$message.error(`切换 OBS 推流状态失败: ${err?.message || err}`)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
isTogglingObsStream.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始推流
|
||||||
|
async function startObsStream() {
|
||||||
|
if (!obs || !obsConnected.value) {
|
||||||
|
console.warn('OBS 未连接,无法开始推流')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obsStreamActive.value) {
|
||||||
|
console.log('OBS 已在推流中')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
isTogglingObsStream.value = true
|
||||||
|
await obs.call('StartStream')
|
||||||
|
obsStreamActive.value = true
|
||||||
|
window.$message.success('已开始 OBS 推流')
|
||||||
|
void updateObsStats()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
console.error('开始 OBS 推流失败:', err)
|
||||||
|
window.$message.error(`开始 OBS 推流失败: ${err?.message || err}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
isTogglingObsStream.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止推流
|
||||||
|
async function stopObsStream() {
|
||||||
|
if (!obs || !obsConnected.value) {
|
||||||
|
console.warn('OBS 未连接,无法停止推流')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!obsStreamActive.value) {
|
||||||
|
console.log('OBS 未在推流中')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
isTogglingObsStream.value = true
|
||||||
|
await obs.call('StopStream')
|
||||||
|
obsStreamActive.value = false
|
||||||
|
window.$message.success('已停止 OBS 推流')
|
||||||
|
void updateObsStats()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
console.error('停止 OBS 推流失败:', err)
|
||||||
|
window.$message.error(`停止 OBS 推流失败: ${err?.message || err}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
isTogglingObsStream.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同步推流码到 OBS
|
||||||
|
async function syncStreamKeyToObs(server: string, key: string) {
|
||||||
|
if (!obs || !obsConnected.value) {
|
||||||
|
window.$message.error('请先连接 OBS')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取当前的流设置
|
||||||
|
const streamSettings: any = await obs.call('GetStreamServiceSettings')
|
||||||
|
|
||||||
|
// 更新服务器和推流码
|
||||||
|
await obs.call('SetStreamServiceSettings', {
|
||||||
|
streamServiceType: streamSettings.streamServiceType || 'rtmp_custom',
|
||||||
|
streamServiceSettings: {
|
||||||
|
...streamSettings.streamServiceSettings,
|
||||||
|
server: server,
|
||||||
|
key: key
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
window.$message.success('推流码已同步到 OBS')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
console.error('同步推流码到 OBS 失败:', err)
|
||||||
|
window.$message.error(`同步推流码失败: ${err?.message || err}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取OBS场景列表
|
||||||
|
async function fetchObsScenes() {
|
||||||
|
if (!obs || !obsConnected.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sceneList: any = await obs.call('GetSceneList')
|
||||||
|
obsScenes.value = sceneList.scenes.map((scene: any) => scene.sceneName as string)
|
||||||
|
console.log('获取到OBS场景列表:', obsScenes.value)
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
console.error('获取OBS场景列表失败:', err)
|
||||||
|
obsSceneError.value = err?.message || '获取场景列表失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新当前场景
|
||||||
|
async function updateCurrentScene() {
|
||||||
|
if (!obs || !obsConnected.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentScene: any = await obs.call('GetCurrentProgramScene')
|
||||||
|
currentObsScene.value = currentScene.currentProgramSceneName || ''
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
console.error('获取当前场景失败:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换到指定场景
|
||||||
|
async function switchToScene(sceneName: string): Promise<boolean> {
|
||||||
|
if (!obs || !obsConnected.value) {
|
||||||
|
window.$message.error('OBS未连接')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sceneName || !obsScenes.value.includes(sceneName)) {
|
||||||
|
window.$message.error('无效的场景名称')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防止重复切换到相同场景
|
||||||
|
if (currentObsScene.value === sceneName) {
|
||||||
|
console.log(`已在场景: ${sceneName},无需切换`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
isSwitchingScene.value = true
|
||||||
|
obsSceneError.value = ''
|
||||||
|
|
||||||
|
await obs.call('SetCurrentProgramScene', {
|
||||||
|
sceneName: sceneName
|
||||||
|
})
|
||||||
|
|
||||||
|
currentObsScene.value = sceneName
|
||||||
|
console.log(`已切换到场景: ${sceneName}`)
|
||||||
|
window.$message.success(`已切换到场景: ${sceneName}`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
console.error('切换场景失败:', err)
|
||||||
|
obsSceneError.value = err?.message || '切换场景失败'
|
||||||
|
window.$message.error(`切换场景失败: ${err?.message || err}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
isSwitchingScene.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存场景配置
|
||||||
|
async function saveSceneConfig() {
|
||||||
|
try {
|
||||||
|
await tauriStore.set(OBS_SCENE_CONFIG_KEY, obsSceneConfig.value)
|
||||||
|
console.log('场景配置已保存')
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error('保存场景配置失败:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载场景配置
|
||||||
|
async function loadSceneConfig() {
|
||||||
|
try {
|
||||||
|
const saved = await tauriStore.get<ObsSceneConfig | null>(OBS_SCENE_CONFIG_KEY)
|
||||||
|
if (saved) {
|
||||||
|
obsSceneConfig.value = saved
|
||||||
|
console.log('已加载场景配置:', saved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error('加载场景配置失败:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载OBS配置
|
||||||
|
async function loadObsConfig() {
|
||||||
|
try {
|
||||||
|
const saved = await tauriStore.get<ObsConfigState | null>(OBS_CONFIG_KEY)
|
||||||
|
if (saved?.address) {
|
||||||
|
obsAddress.value = saved.address
|
||||||
|
// 只有在设置了地址和密码时才启用自动重连
|
||||||
|
if (saved?.password !== undefined) {
|
||||||
|
obsPassword.value = saved.password || ''
|
||||||
|
obsAutoReconnect.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error('加载OBS配置失败:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
async function init() {
|
||||||
|
await loadObsConfig()
|
||||||
|
await loadSceneConfig()
|
||||||
|
|
||||||
|
// 只有在设置了地址和密码后才启动自动重连
|
||||||
|
if (obsAutoReconnect.value && obsAddress.value) {
|
||||||
|
startObsAutoReconnectLoop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理资源
|
||||||
|
function cleanup() {
|
||||||
|
stopObsAutoReconnectLoop()
|
||||||
|
stopObsStatsLoop()
|
||||||
|
if (obs) {
|
||||||
|
void obs.disconnect().catch(() => {})
|
||||||
|
obs = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
obsAddress,
|
||||||
|
obsPassword,
|
||||||
|
obsConnected,
|
||||||
|
obsConnecting,
|
||||||
|
obsError,
|
||||||
|
obsAutoReconnect,
|
||||||
|
obsStreamActive,
|
||||||
|
obsStreamReconnecting,
|
||||||
|
isTogglingObsStream,
|
||||||
|
obsStats,
|
||||||
|
obsScenes,
|
||||||
|
currentObsScene,
|
||||||
|
isSwitchingScene,
|
||||||
|
obsSceneError,
|
||||||
|
obsSceneConfig,
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
handleObsConnect,
|
||||||
|
handleObsDisconnect,
|
||||||
|
handleObsToggleStream,
|
||||||
|
startObsStream,
|
||||||
|
stopObsStream,
|
||||||
|
syncStreamKeyToObs,
|
||||||
|
fetchObsScenes,
|
||||||
|
updateCurrentScene,
|
||||||
|
switchToScene,
|
||||||
|
saveSceneConfig,
|
||||||
|
loadSceneConfig,
|
||||||
|
loadObsConfig,
|
||||||
|
init,
|
||||||
|
cleanup,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 热模块替换支持
|
||||||
|
if (import.meta.hot) {
|
||||||
|
import.meta.hot.accept(acceptHMRUpdate(useOBSStore, import.meta.hot))
|
||||||
|
}
|
||||||
@@ -19,6 +19,9 @@ export interface VTsuruClientSettings {
|
|||||||
danmakuInterval: number
|
danmakuInterval: number
|
||||||
pmInterval: number
|
pmInterval: number
|
||||||
|
|
||||||
|
// EventFetcher 功能开关
|
||||||
|
enableEventFetcher: boolean
|
||||||
|
|
||||||
dev_disableDanmakuClient: boolean
|
dev_disableDanmakuClient: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +43,9 @@ export const useSettings = defineStore('settings', () => {
|
|||||||
danmakuInterval: 2000,
|
danmakuInterval: 2000,
|
||||||
pmInterval: 2000,
|
pmInterval: 2000,
|
||||||
|
|
||||||
|
// 默认启用 EventFetcher
|
||||||
|
enableEventFetcher: true,
|
||||||
|
|
||||||
dev_disableDanmakuClient: false,
|
dev_disableDanmakuClient: false,
|
||||||
}
|
}
|
||||||
const settings = ref<VTsuruClientSettings>(Object.assign({}, defaultSettings))
|
const settings = ref<VTsuruClientSettings>(Object.assign({}, defaultSettings))
|
||||||
@@ -51,6 +57,8 @@ export const useSettings = defineStore('settings', () => {
|
|||||||
// 初始化消息队列间隔设置
|
// 初始化消息队列间隔设置
|
||||||
settings.value.danmakuInterval ??= defaultSettings.danmakuInterval
|
settings.value.danmakuInterval ??= defaultSettings.danmakuInterval
|
||||||
settings.value.pmInterval ??= defaultSettings.pmInterval
|
settings.value.pmInterval ??= defaultSettings.pmInterval
|
||||||
|
// 初始化 EventFetcher 开关
|
||||||
|
settings.value.enableEventFetcher ??= defaultSettings.enableEventFetcher
|
||||||
}
|
}
|
||||||
async function save() {
|
async function save() {
|
||||||
await store.set(settings.value)
|
await store.set(settings.value)
|
||||||
|
|||||||
94
src/components.d.ts
vendored
94
src/components.d.ts
vendored
@@ -1,8 +1,12 @@
|
|||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
|
// biome-ignore lint: disable
|
||||||
|
// oxlint-disable
|
||||||
|
// ------
|
||||||
// Generated by unplugin-vue-components
|
// Generated by unplugin-vue-components
|
||||||
// Read more: https://github.com/vuejs/core/pull/3399
|
// Read more: https://github.com/vuejs/core/pull/3399
|
||||||
// biome-ignore lint: disable
|
import { GlobalComponents } from 'vue'
|
||||||
|
|
||||||
export {}
|
export {}
|
||||||
|
|
||||||
/* prettier-ignore */
|
/* prettier-ignore */
|
||||||
@@ -19,11 +23,35 @@ declare module 'vue' {
|
|||||||
LabelItem: typeof import('./components/LabelItem.vue')['default']
|
LabelItem: typeof import('./components/LabelItem.vue')['default']
|
||||||
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']
|
||||||
|
NAvatar: typeof import('naive-ui')['NAvatar']
|
||||||
|
NButton: typeof import('naive-ui')['NButton']
|
||||||
|
NCard: typeof import('naive-ui')['NCard']
|
||||||
|
NCascader: typeof import('naive-ui')['NCascader']
|
||||||
|
NDivider: typeof import('naive-ui')['NDivider']
|
||||||
|
NEllipsis: typeof import('naive-ui')['NEllipsis']
|
||||||
|
NEmpty: typeof import('naive-ui')['NEmpty']
|
||||||
NFlex: typeof import('naive-ui')['NFlex']
|
NFlex: typeof import('naive-ui')['NFlex']
|
||||||
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
|
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
|
||||||
|
NGi: typeof import('naive-ui')['NGi']
|
||||||
|
NGrid: typeof import('naive-ui')['NGrid']
|
||||||
NGridItem: typeof import('naive-ui')['NGridItem']
|
NGridItem: typeof import('naive-ui')['NGridItem']
|
||||||
|
NIcon: typeof import('naive-ui')['NIcon']
|
||||||
|
NImage: typeof import('naive-ui')['NImage']
|
||||||
|
NInput: typeof import('naive-ui')['NInput']
|
||||||
|
NInputGroup: typeof import('naive-ui')['NInputGroup']
|
||||||
|
NModal: typeof import('naive-ui')['NModal']
|
||||||
|
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
|
||||||
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||||
|
NSelect: typeof import('naive-ui')['NSelect']
|
||||||
|
NSpace: typeof import('naive-ui')['NSpace']
|
||||||
|
NStatistic: typeof import('naive-ui')['NStatistic']
|
||||||
|
NSwitch: typeof import('naive-ui')['NSwitch']
|
||||||
NTag: typeof import('naive-ui')['NTag']
|
NTag: typeof import('naive-ui')['NTag']
|
||||||
|
NText: typeof import('naive-ui')['NText']
|
||||||
|
NTime: typeof import('naive-ui')['NTime']
|
||||||
|
NTooltip: typeof import('naive-ui')['NTooltip']
|
||||||
|
NUpload: typeof import('naive-ui')['NUpload']
|
||||||
PointGoodsItem: typeof import('./components/manage/PointGoodsItem.vue')['default']
|
PointGoodsItem: typeof import('./components/manage/PointGoodsItem.vue')['default']
|
||||||
PointHistoryCard: typeof import('./components/manage/PointHistoryCard.vue')['default']
|
PointHistoryCard: typeof import('./components/manage/PointHistoryCard.vue')['default']
|
||||||
PointOrderCard: typeof import('./components/manage/PointOrderCard.vue')['default']
|
PointOrderCard: typeof import('./components/manage/PointOrderCard.vue')['default']
|
||||||
@@ -46,3 +74,67 @@ declare module 'vue' {
|
|||||||
VideoCollectInfoCard: typeof import('./components/VideoCollectInfoCard.vue')['default']
|
VideoCollectInfoCard: typeof import('./components/VideoCollectInfoCard.vue')['default']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For TSX support
|
||||||
|
declare global {
|
||||||
|
const AddressDisplay: typeof import('./components/manage/AddressDisplay.vue')['default']
|
||||||
|
const BiliUserSelector: typeof import('./components/common/BiliUserSelector.vue')['default']
|
||||||
|
const DanmakuContainer: typeof import('./components/DanmakuContainer.vue')['default']
|
||||||
|
const DanmakuItem: typeof import('./components/DanmakuItem.vue')['default']
|
||||||
|
const DynamicForm: typeof import('./components/DynamicForm.vue')['default']
|
||||||
|
const EventFetcherAlert: typeof import('./components/EventFetcherAlert.vue')['default']
|
||||||
|
const EventFetcherStatusCard: typeof import('./components/EventFetcherStatusCard.vue')['default']
|
||||||
|
const FeedbackItem: typeof import('./components/FeedbackItem.vue')['default']
|
||||||
|
const LabelItem: typeof import('./components/LabelItem.vue')['default']
|
||||||
|
const LiveInfoContainer: typeof import('./components/LiveInfoContainer.vue')['default']
|
||||||
|
const MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default']
|
||||||
|
const NAlert: typeof import('naive-ui')['NAlert']
|
||||||
|
const NAvatar: typeof import('naive-ui')['NAvatar']
|
||||||
|
const NButton: typeof import('naive-ui')['NButton']
|
||||||
|
const NCard: typeof import('naive-ui')['NCard']
|
||||||
|
const NCascader: typeof import('naive-ui')['NCascader']
|
||||||
|
const NDivider: typeof import('naive-ui')['NDivider']
|
||||||
|
const NEllipsis: typeof import('naive-ui')['NEllipsis']
|
||||||
|
const NEmpty: typeof import('naive-ui')['NEmpty']
|
||||||
|
const NFlex: typeof import('naive-ui')['NFlex']
|
||||||
|
const NFormItemGi: typeof import('naive-ui')['NFormItemGi']
|
||||||
|
const NGi: typeof import('naive-ui')['NGi']
|
||||||
|
const NGrid: typeof import('naive-ui')['NGrid']
|
||||||
|
const NGridItem: typeof import('naive-ui')['NGridItem']
|
||||||
|
const NIcon: typeof import('naive-ui')['NIcon']
|
||||||
|
const NImage: typeof import('naive-ui')['NImage']
|
||||||
|
const NInput: typeof import('naive-ui')['NInput']
|
||||||
|
const NInputGroup: typeof import('naive-ui')['NInputGroup']
|
||||||
|
const NModal: typeof import('naive-ui')['NModal']
|
||||||
|
const NPopconfirm: typeof import('naive-ui')['NPopconfirm']
|
||||||
|
const NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||||
|
const NSelect: typeof import('naive-ui')['NSelect']
|
||||||
|
const NSpace: typeof import('naive-ui')['NSpace']
|
||||||
|
const NStatistic: typeof import('naive-ui')['NStatistic']
|
||||||
|
const NSwitch: typeof import('naive-ui')['NSwitch']
|
||||||
|
const NTag: typeof import('naive-ui')['NTag']
|
||||||
|
const NText: typeof import('naive-ui')['NText']
|
||||||
|
const NTime: typeof import('naive-ui')['NTime']
|
||||||
|
const NTooltip: typeof import('naive-ui')['NTooltip']
|
||||||
|
const NUpload: typeof import('naive-ui')['NUpload']
|
||||||
|
const PointGoodsItem: typeof import('./components/manage/PointGoodsItem.vue')['default']
|
||||||
|
const PointHistoryCard: typeof import('./components/manage/PointHistoryCard.vue')['default']
|
||||||
|
const PointOrderCard: typeof import('./components/manage/PointOrderCard.vue')['default']
|
||||||
|
const QuestionItem: typeof import('./components/QuestionItem.vue')['default']
|
||||||
|
const QuestionItems: typeof import('./components/QuestionItems.vue')['default']
|
||||||
|
const RegisterAndLogin: typeof import('./components/RegisterAndLogin.vue')['default']
|
||||||
|
const RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
|
const RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
const SaveCompoent: typeof import('./components/SaveCompoent.vue')['default']
|
||||||
|
const ScheduleList: typeof import('./components/ScheduleList.vue')['default']
|
||||||
|
const SimpleVideoCard: typeof import('./components/SimpleVideoCard.vue')['default']
|
||||||
|
const SimpleVirtualList: typeof import('./components/SimpleVirtualList.vue')['default']
|
||||||
|
const SongList: typeof import('./components/SongList.vue')['default']
|
||||||
|
const SongPlayer: typeof import('./components/SongPlayer.vue')['default']
|
||||||
|
const TempComponent: typeof import('./components/TempComponent.vue')['default']
|
||||||
|
const TurnstileVerify: typeof import('./components/TurnstileVerify.vue')['default']
|
||||||
|
const UpdateNoteContainer: typeof import('./components/UpdateNoteContainer.vue')['default']
|
||||||
|
const UserBasicInfoCard: typeof import('./components/UserBasicInfoCard.vue')['default']
|
||||||
|
const VEditor: typeof import('./components/VEditor.vue')['default']
|
||||||
|
const VideoCollectInfoCard: typeof import('./components/VideoCollectInfoCard.vue')['default']
|
||||||
|
}
|
||||||
@@ -3,6 +3,29 @@ import { NButton, NImage } from 'naive-ui'
|
|||||||
import UpdateNoteContainer from '@/components/UpdateNoteContainer.vue'
|
import UpdateNoteContainer from '@/components/UpdateNoteContainer.vue'
|
||||||
|
|
||||||
export const updateNotes: updateNoteType[] = [
|
export const updateNotes: updateNoteType[] = [
|
||||||
|
{
|
||||||
|
ver: 9,
|
||||||
|
date: '2025.11.17',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
type: 'new',
|
||||||
|
title: 'VTsuru Client 新增直播管理功能',
|
||||||
|
content: [
|
||||||
|
[
|
||||||
|
() => h(NButton, {
|
||||||
|
text: true,
|
||||||
|
tag: 'a',
|
||||||
|
href: 'https://www.wolai.com/carN6qvUm3FErze9Xo53ii',
|
||||||
|
target: '_blank',
|
||||||
|
type: 'info',
|
||||||
|
}, () => 'VTsuru Client '),
|
||||||
|
' 新增直播管理功能, 允许直接开播下播并使用OBS推流, 不再依赖直播姬\r\n',
|
||||||
|
() => h(NImage, { src: 'https://files.vtsuru.suki.club/updatelog/QQ20251117-182002.png', width: 300 }),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ver: 8,
|
ver: 8,
|
||||||
date: '2025.10.16',
|
date: '2025.10.16',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Component, DefineComponent } from 'vue'
|
import { defineAsyncComponent, type Component, DefineComponent } from 'vue'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OBS 组件定义接口
|
* OBS 组件定义接口
|
||||||
|
|||||||
@@ -59,6 +59,15 @@ export default {
|
|||||||
forceReload: true,
|
forceReload: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'live-manage',
|
||||||
|
name: 'client-live-manage',
|
||||||
|
component: async () => import('@/client/ClientLiveManage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '直播管理',
|
||||||
|
forceReload: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'danmaku-window',
|
path: 'danmaku-window',
|
||||||
name: 'client-danmaku-window-redirect',
|
name: 'client-danmaku-window-redirect',
|
||||||
|
|||||||
@@ -655,6 +655,10 @@ onMounted(() => {
|
|||||||
canResendEmail.value = true
|
canResendEmail.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedAPIKey.value != 'main') {
|
||||||
|
message.warning('你当前使用的是备用API节点, 可能会速度比较慢')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user