feat: Add Tauri support and enhance client functionality

- Introduced Tauri as a new EventFetcherType in api-models.
- Enhanced ClientFetcher.vue to support forced mode switching for Danmaku client.
- Updated ClientLayout.vue to restrict usage outside Tauri environment with appropriate alerts.
- Improved ClientSettings.vue to fetch and display the current version of the application.
- Modified initialization logic in initialize.ts to handle minimized startup for Tauri.
- Updated QueryBiliAPI function to conditionally use cookies based on a new parameter.
- Added bootAsMinimized setting to useSettings store for better user experience.
- Refactored logging in useWebFetcher to use console instead of logError/logInfo for clarity.
- Created a new LabelItem component for better label handling in forms.
- Enhanced EventFetcherStatusCard.vue to display version information based on EventFetcherType.
This commit is contained in:
2025-04-07 19:14:39 +08:00
parent 277497420c
commit 0195e7b01a
14 changed files with 536 additions and 328 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -15,6 +15,7 @@
"@microsoft/signalr": "^8.0.7",
"@microsoft/signalr-protocol-msgpack": "^8.0.7",
"@mixer/postmessage-rpc": "^1.1.4",
"@oneidentity/zstd-js": "^1.0.3",
"@tauri-apps/api": "^2.4.0",
"@tauri-apps/plugin-autostart": "^2.3.0",
"@tauri-apps/plugin-http": "^2.4.2",

View File

@@ -1,4 +1,4 @@
import { useStorage } from '@vueuse/core'
import { useStorage } from '@vueuse/core';
import {
ConfigProviderProps,
NButton,
@@ -10,114 +10,114 @@ import {
dateZhCN,
useOsTheme,
zhCN
} from 'naive-ui'
import { SongFrom, SongsInfo, ThemeType } from './api/api-models'
import { computed } from 'vue'
import { VTSURU_API_URL } from './data/constants'
import { DiscreteApiType } from 'naive-ui/es/discrete/src/interface'
} from 'naive-ui';
import { SongFrom, SongsInfo, ThemeType } from './api/api-models';
import { computed } from 'vue';
import { VTSURU_API_URL } from './data/constants';
import { DiscreteApiType } from 'naive-ui/es/discrete/src/interface';
import { SquareArrowForward24Filled } from '@vicons/fluent';
import FiveSingIcon from '@/svgs/fivesing.svg'
import NeteaseIcon from '@/svgs/netease.svg'
import FiveSingIcon from '@/svgs/fivesing.svg';
import NeteaseIcon from '@/svgs/netease.svg';
const { message } = createDiscreteApi(['message'])
const { message } = createDiscreteApi(['message']);
const osThemeRef = useOsTheme() //获取当前系统主题
const themeType = useStorage('Settings.Theme', ThemeType.Auto)
const osThemeRef = useOsTheme(); //获取当前系统主题
const themeType = useStorage('Settings.Theme', ThemeType.Auto);
export const theme = computed(() => {
if (themeType.value == ThemeType.Auto) {
var osThemeRef = useOsTheme() //获取当前系统主题
return osThemeRef.value === 'dark' ? darkTheme : null
var osThemeRef = useOsTheme(); //获取当前系统主题
return osThemeRef.value === 'dark' ? darkTheme : null;
} else {
return themeType.value == ThemeType.Dark ? darkTheme : null
return themeType.value == ThemeType.Dark ? darkTheme : null;
}
})
});
export const configProviderPropsRef = computed<ConfigProviderProps>(() => ({
theme: theme.value,
locale: zhCN,
dateLocale: dateZhCN,
}))
}));
export function createNaiveUIApi(types: DiscreteApiType[]) {
return createDiscreteApi(types, {
configProviderProps: configProviderPropsRef
})
});
}
export function NavigateToNewTab(url: string) {
window.open(url, '_blank')
window.open(url, '_blank');
}
export const isDarkMode = computed(() => {
if (themeType.value == ThemeType.Auto) return osThemeRef.value === 'dark'
else return themeType.value == ThemeType.Dark
})
if (themeType.value == ThemeType.Auto) return osThemeRef.value === 'dark';
else return themeType.value == ThemeType.Dark;
});
export function copyToClipboard(text: string) {
if (navigator.clipboard) {
navigator.clipboard.writeText(text)
message.success('已复制到剪切板')
navigator.clipboard.writeText(text);
message.success('已复制到剪切板');
} else {
message.warning('当前环境不支持自动复制, 请手动选择并复制')
message.warning('当前环境不支持自动复制, 请手动选择并复制');
}
}
export function objectsToCSV(arr: any[]) {
const array = [Object.keys(arr[0])].concat(arr)
const array = [Object.keys(arr[0])].concat(arr);
return array
.map((row) => {
return Object.values(row)
.map((value) => {
return typeof value === 'string' ? JSON.stringify(value) : value
return typeof value === 'string' ? JSON.stringify(value) : value;
})
.toString()
.toString();
})
.join('\n')
.join('\n');
}
export function GetGuardColor(level: number | null | undefined): string {
if (level) {
switch (level) {
case 1: {
return 'rgb(122, 4, 35)'
return 'rgb(122, 4, 35)';
}
case 2: {
return 'rgb(157, 155, 255)'
return 'rgb(157, 155, 255)';
}
case 3: {
return 'rgb(104, 136, 241)'
return 'rgb(104, 136, 241)';
}
}
}
return ''
return '';
}
export function downloadImage(imageSrc: string, filename: string) {
const image = new Image()
image.crossOrigin = 'Anonymous' // This might be needed depending on the image's server
const image = new Image();
image.crossOrigin = 'Anonymous'; // This might be needed depending on the image's server
image.onload = () => {
const canvas = document.createElement('canvas')
canvas.width = image.width
canvas.height = image.height
const ctx = canvas.getContext('2d')
ctx!.drawImage(image, 0, 0)
const canvas = document.createElement('canvas');
canvas.width = image.width;
canvas.height = image.height;
const ctx = canvas.getContext('2d');
ctx!.drawImage(image, 0, 0);
canvas.toBlob((blob) => {
if (blob) {
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
}) // Omitted the 'image/jpeg' to use the original image format
}
image.src = imageSrc
}); // Omitted the 'image/jpeg' to use the original image format
};
image.src = imageSrc;
}
export function getBase64(
file: File | undefined | null
): Promise<string | undefined> {
if (!file) return new Promise((resolve) => resolve(undefined))
if (!file) return new Promise((resolve) => resolve(undefined));
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.readAsDataURL(file)
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () =>
resolve(reader.result?.toString().split(',')[1] || undefined)
reader.onerror = (error) => reject(error)
})
resolve(reader.result?.toString().split(',')[1] || undefined);
reader.onerror = (error) => reject(error);
});
}
export async function getImageUploadModel(
files: UploadFileInfo[] | undefined | null,
@@ -126,83 +126,83 @@ export async function getImageUploadModel(
const result = {
existImages: [],
newImagesBase64: []
} as { existImages: string[]; newImagesBase64: string[] }
if (!files) return result
} as { existImages: string[]; newImagesBase64: string[]; };
if (!files) return result;
for (let i = 0; i < files.length; i++) {
const file = files[i]
const file = files[i];
if ((file.file?.size ?? 0) > maxSize) {
message.error('文件大小不能超过 ' + maxSize / 1024 / 1024 + 'MB')
return result
message.error('文件大小不能超过 ' + maxSize / 1024 / 1024 + 'MB');
return result;
}
if (!file.file) {
result.existImages.push(file.id) //用id绝对路径当的文件名
result.existImages.push(file.id); //用id绝对路径当的文件名
} else {
const base64 = await getBase64(file.file)
const base64 = await getBase64(file.file);
if (base64) {
result.newImagesBase64.push(base64)
result.newImagesBase64.push(base64);
}
}
}
return result
return result;
}
export function getUserAvatarUrl(userId: number | undefined | null) {
if (!userId) return ''
return VTSURU_API_URL + 'user-face/' + userId
if (!userId) return '';
return VTSURU_API_URL + 'user-face/' + userId;
}
export function getOUIdAvatarUrl(ouid: string) {
return VTSURU_API_URL + 'face/' + ouid
return VTSURU_API_URL + 'face/' + ouid;
}
export class GuidUtils {
// 将数字转换为GUID
public static numToGuid(value: number): string {
const buffer = new ArrayBuffer(16)
const view = new DataView(buffer)
view.setBigUint64(8, BigInt(value)) // 将数字写入后8个字节
return GuidUtils.bufferToGuid(buffer)
const buffer = new ArrayBuffer(16);
const view = new DataView(buffer);
view.setBigUint64(8, BigInt(value)); // 将数字写入后8个字节
return GuidUtils.bufferToGuid(buffer);
}
// 检查GUID是否由数字生成
public static isGuidFromUserId(guid: string): boolean {
const buffer = GuidUtils.guidToBuffer(guid)
const view = new DataView(buffer)
const buffer = GuidUtils.guidToBuffer(guid);
const view = new DataView(buffer);
for (let i = 0; i < 8; i++) {
if (view.getUint8(i) !== 0) return false // 检查前8个字节是否为0
if (view.getUint8(i) !== 0) return false; // 检查前8个字节是否为0
}
return true
return true;
}
// 将GUID转换为数字
public static guidToLong(guid: string): number {
const buffer = GuidUtils.guidToBuffer(guid)
const view = new DataView(buffer)
return Number(view.getBigUint64(8))
const buffer = GuidUtils.guidToBuffer(guid);
const view = new DataView(buffer);
return Number(view.getBigUint64(8));
}
// 辅助方法将ArrayBuffer转换为GUID字符串
private static bufferToGuid(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
const bytes = new Uint8Array(buffer);
const guid = bytes.reduce((str, byte, idx) => {
const pair = byte.toString(16).padStart(2, '0')
const pair = byte.toString(16).padStart(2, '0');
return (
str +
pair +
(idx === 3 || idx === 5 || idx === 7 || idx === 9 ? '-' : '')
)
}, '')
return guid
);
}, '');
return guid;
}
// 辅助方法将GUID字符串转换为ArrayBuffer
private static guidToBuffer(guid: string): ArrayBuffer {
const hex = guid.replace(/-/g, '')
if (hex.length !== 32) throw new Error('Invalid GUID format.')
const buffer = new ArrayBuffer(16)
const view = new DataView(buffer)
const hex = guid.replace(/-/g, '');
if (hex.length !== 32) throw new Error('Invalid GUID format.');
const buffer = new ArrayBuffer(16);
const view = new DataView(buffer);
for (let i = 0; i < 16; i++) {
view.setUint8(i, parseInt(hex.substr(i * 2, 2), 16))
view.setUint8(i, parseInt(hex.substr(i * 2, 2), 16));
}
return buffer
return buffer;
}
}
export function GetPlayButton(song: SongsInfo) {
@@ -218,7 +218,7 @@ export function GetPlayButton(song: SongsInfo) {
color: '#00BBB3',
ghost: true,
onClick: () => {
window.open(`http://5sing.kugou.com/bz/${song.id}.html`)
window.open(`http://5sing.kugou.com/bz/${song.id}.html`);
},
},
{
@@ -227,7 +227,7 @@ export function GetPlayButton(song: SongsInfo) {
),
),
default: () => '在5sing打开',
})
});
}
case SongFrom.Netease:
return h(NTooltip, null, {
@@ -239,7 +239,7 @@ export function GetPlayButton(song: SongsInfo) {
color: '#C20C0C',
ghost: true,
onClick: () => {
window.open(`https://music.163.com/#/song?id=${song.id}`)
window.open(`https://music.163.com/#/song?id=${song.id}`);
},
},
{
@@ -247,7 +247,7 @@ export function GetPlayButton(song: SongsInfo) {
},
),
default: () => '在网易云打开',
})
});
case SongFrom.Custom:
return song.url
? h(NTooltip, null, {
@@ -259,7 +259,7 @@ export function GetPlayButton(song: SongsInfo) {
color: '#6b95bd',
ghost: true,
onClick: () => {
window.open(song.url)
window.open(song.url);
},
},
{
@@ -268,6 +268,33 @@ export function GetPlayButton(song: SongsInfo) {
),
default: () => '打开链接',
})
: null
: null;
}
}
export function getBrowserName() {
var userAgent = navigator.userAgent;
if (userAgent.indexOf("Opera") > -1 || userAgent.indexOf("OPR") > -1) {
return 'Opera';
}
else if (userAgent.indexOf("compatible") > -1 && userAgent.indexOf("MSIE") > -1) {
return 'IE';
}
else if (userAgent.indexOf("Edge") > -1) {
return 'Edge';
}
else if (userAgent.indexOf("Firefox") > -1) {
return 'Firefox';
}
else if (userAgent.indexOf("Safari") > -1 && userAgent.indexOf("Chrome") == -1) {
return 'Safari';
}
else if (userAgent.indexOf("Chrome") > -1 && userAgent.indexOf("Safari") > -1) {
return 'Chrome';
}
else if (!!window.ActiveXObject || "ActiveXObject" in window) {
return 'IE>=11';
}
else {
return 'Unkonwn';
}
}

View File

@@ -54,6 +54,7 @@ export interface EventFetcherStateModel {
export enum EventFetcherType {
Application,
OBS,
Tauri,
Server
}
export interface AccountInfo extends UserInfo {

View File

@@ -357,8 +357,8 @@
const minutes = duration.minutes || 0; const seconds = duration.seconds || 0;
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
async function onSwitchDanmakuClientMode(type: 'openlive' | 'direct') {
if (webfetcher.webfetcherType === type) {
async function onSwitchDanmakuClientMode(type: 'openlive' | 'direct', force: boolean = false) {
if (webfetcher.webfetcherType === type && !force) {
message.info('当前已是该模式'); return;
}
const noticeRef = window.$notification.info({
@@ -494,8 +494,7 @@
embedded
style="width: 100%; max-width: 800px;"
>
<NFlex align="center">
<NText>模式:</NText>
<NFlex vertical>
<NRadioGroup
v-model:value="settings.settings.useDanmakuClientType"
:disabled="webfetcher.state === 'connecting'"
@@ -519,6 +518,27 @@
</NText>
</NRadioButton>
</NRadioGroup>
<NPopconfirm
type="info"
:disabled="webfetcher.state === 'connecting'"
@positive-click="async () => {
await onSwitchDanmakuClientMode(settings.settings.useDanmakuClientType, true);
message.success('已重启弹幕服务器');
}"
>
<template #trigger>
<NButton
type="error"
style="max-width: 150px;"
:disabled="webfetcher.state === 'connecting'"
>
强制重启弹幕客户端
</NButton>
</template>
<template #default>
确定要强制重启弹幕服务器吗?
</template>
</NPopconfirm>
</NFlex>
</NCard>
@@ -789,6 +809,7 @@
bordered
:columns="2"
size="small"
style="overflow-x: auto;"
>
<NDescriptionsItem label="启动时间">
<NIcon :component="TimeOutline" /> {{ formattedStartedAt }}
@@ -800,6 +821,7 @@
<NFlex
align="center"
size="small"
:wrap="false"
>
<NTag
:type="signalRStateType"
@@ -807,8 +829,8 @@
>
{{ signalRStateText }}
</NTag>
<NEllipsis style="max-width: 200px;">
{{ webfetcher.signalRClient?.connectionId ?? 'N/A' }}
<NEllipsis style="max-width: 150px;">
{{ webfetcher.signalRId ?? 'N/A' }}
</NEllipsis>
</NFlex>
</NDescriptionsItem>
@@ -816,6 +838,7 @@
<NFlex
align="center"
size="small"
:wrap="false"
>
<NTag
:type="danmakuClientStateType"
@@ -823,7 +846,7 @@
>
{{ danmakuClientStateText }}
</NTag>
<NEllipsis style="max-width: 200px;">
<NEllipsis style="max-width: 150px;">
{{ webfetcher.danmakuServerUrl ?? 'N/A' }}
</NEllipsis> <!-- Assuming this is exposed -->
</NFlex>
@@ -968,10 +991,6 @@
<NStatistic label="会话事件总数">
<NIcon :component="BarChartOutline" /> {{ webfetcher.sessionEventCount?.toLocaleString() ?? 0 }}
</NStatistic>
<NStatistic label="数据上传">
<NIcon :component="DownloadOutline" /> 成功: {{ webfetcher.successfulUploads ?? 0 }} / 失败:
{{ webfetcher.failedUploads ?? 0 }} <!-- Assuming exposed -->
</NStatistic>
<NStatistic label="已发送">
{{ ((webfetcher.bytesSentSession ?? 0) / 1024).toFixed(2) }} KB <!-- Assuming exposed -->
</NStatistic>

View File

@@ -17,6 +17,7 @@
import WindowBar from './WindowBar.vue';
import { initAll, OnClientUnmounted } from './data/initialize';
import { CloudArchive24Filled, Settings24Filled } from '@vicons/fluent';
import { isTauri } from '@/data/constants';
// --- 响应式状态 ---
@@ -68,7 +69,7 @@ import { CloudArchive24Filled, Settings24Filled } from '@vicons/fluent';
window.$message.success('登陆成功');
ACCOUNT.value = result; // 更新全局账户信息
// isLoadingAccount.value = false; // 状态在 finally 中统一处理
initAll(); // 初始化 WebFetcher
//initAll(false); // 初始化 WebFetcher
}
}
} catch (error) {
@@ -116,160 +117,169 @@ import { CloudArchive24Filled, Settings24Filled } from '@vicons/fluent';
</script>
<template>
<WindowBar />
<div
v-if="!isLoggedIn"
class="login-container"
<NAlert
v-if="!isTauri"
type="error"
title="错误"
>
<NCard
v-if="!isLoadingAccount"
:bordered="false"
size="large"
class="login-card"
>
<template #header>
<div class="login-header">
<div class="login-title">
登陆
</div>
<div class="login-subtitle">
输入你的 VTsuru Token
</div>
</div>
</template>
此应用在 Tauri 环境外运行无法使用
</NAlert>
<template v-else>
<WindowBar />
<NSpace
vertical
<div
v-if="!isLoggedIn"
class="login-container"
>
<NCard
v-if="!isLoadingAccount"
:bordered="false"
size="large"
class="login-card"
>
<NSpace vertical>
<div class="token-label-container">
<span class="token-label">Token</span>
<NTooltip placement="top">
<template #header>
<div class="login-header">
<div class="login-title">
登陆
</div>
<div class="login-subtitle">
输入你的 VTsuru Token
</div>
</div>
</template>
<NSpace
vertical
size="large"
>
<NSpace vertical>
<div class="token-label-container">
<span class="token-label">Token</span>
<NTooltip placement="top">
<template #trigger>
<NA
class="token-get-link"
@click="openUrl('https://vtsuru.suki.club/manage')"
>
前往获取
</NA>
</template>
登录后在管理面板主页的个人信息下方
</NTooltip>
</div>
<NInput
v-model:value="token"
type="password"
show-password-on="click"
placeholder="请输入Token"
@keyup.enter="login"
/>
</NSpace>
<NButton
block
type="primary"
:loading="isLoadingAccount"
:disabled="isLoadingAccount"
@click="login"
>
登陆
</NButton>
</NSpace>
</NCard>
<NSpin
v-else
size="large"
/>
</div>
<NLayout
v-else
has-sider
class="main-layout"
@vue:mounted="initAll(true)"
>
<NLayoutSider
width="200"
bordered
class="main-layout-sider"
>
<div class="sider-content">
<div class="sider-header">
<NText
tag="div"
class="app-title"
>
<span>VTsuru.Client</span>
</NText>
<NTooltip trigger="hover">
<template #trigger>
<NA
class="token-get-link"
@click="openUrl('https://vtsuru.suki.club/manage')"
<NButton
quaternary
class="fetcher-status-button"
:type="webfetcher.state === 'connected' ? 'success' : 'error'"
>
前往获取
</NA>
<CheckmarkCircle
v-if="webfetcher.state === 'connected'"
class="fetcher-status-icon connected"
/>
<CloseCircle
v-else
class="fetcher-status-icon disconnected"
/>
</NButton>
</template>
登录后在管理面板主页的个人信息下方
<div>
<div>EventFetcher 状态</div>
<div v-if="webfetcher.state === 'connected'">
运行中
</div>
<div v-else>
未运行 / 连接断开
</div>
</div>
</NTooltip>
</div>
<NInput
v-model:value="token"
type="password"
show-password-on="click"
placeholder="请输入Token"
@keyup.enter="login"
<NMenu
:options="menuOptions"
:default-value="'go-back-home'"
class="sider-menu"
/>
</NSpace>
<NButton
block
type="primary"
:loading="isLoadingAccount"
:disabled="isLoadingAccount"
@click="login"
>
登陆
</NButton>
</NSpace>
</NCard>
<NSpin
v-else
size="large"
/>
</div>
<NLayout
v-else
has-sider
class="main-layout"
@vue:mounted="initAll()"
>
<NLayoutSider
width="200"
bordered
class="main-layout-sider"
>
<div class="sider-content">
<div class="sider-header">
<NText
tag="div"
class="app-title"
>
<span>VTsuru.Client</span>
</NText>
<NTooltip trigger="hover">
<template #trigger>
<NButton
quaternary
class="fetcher-status-button"
:type="webfetcher.state === 'connected' ? 'success' : 'error'"
>
<CheckmarkCircle
v-if="webfetcher.state === 'connected'"
class="fetcher-status-icon connected"
/>
<CloseCircle
v-else
class="fetcher-status-icon disconnected"
/>
</NButton>
</template>
<div>
<div>EventFetcher 状态</div>
<div v-if="webfetcher.state === 'connected'">
运行中
</div>
<div v-else>
未运行 / 连接断开
</div>
</div>
</NTooltip>
</div>
</NLayoutSider>
<NMenu
:options="menuOptions"
:default-value="'go-back-home'"
class="sider-menu"
/>
</div>
</NLayoutSider>
<NLayoutContent
class="main-layout-content"
:native-scrollbar="false"
:scrollbar-props="{
trigger: 'none'
}"
>
<div style="padding: 12px; padding-right: 15px;">
<RouterView v-slot="{ Component }">
<Transition
name="fade-slide"
mode="out-in"
:appear="true"
>
<KeepAlive>
<Suspense>
<component :is="Component" />
<template #fallback>
<div class="suspense-fallback">
加载中...
</div>
</template>
</Suspense>
</KeepAlive>
</Transition>
</RouterView>
</div>
</NLayoutContent>
</NLayout>
<NLayoutContent
class="main-layout-content"
:native-scrollbar="false"
:scrollbar-props="{
trigger: 'none'
}"
>
<div style="padding: 12px; padding-right: 15px;">
<RouterView v-slot="{ Component }">
<Transition
name="fade-slide"
mode="out-in"
:appear="true"
>
<KeepAlive>
<Suspense>
<component :is="Component" />
<template #fallback>
<div class="suspense-fallback">
加载中...
</div>
</template>
</Suspense>
</KeepAlive>
</Transition>
</RouterView>
</div>
</NLayoutContent>
</NLayout>
</template>
</template>
<style scoped>

View File

@@ -15,11 +15,13 @@ import {
NFormItem, // Added NFormItem
NAlert,
NCheckboxGroup,
NCheckbox, // Added NAlert for error messages
NCheckbox,
NDivider, // Added NAlert for error messages
} from 'naive-ui';
import type { MenuOption } from 'naive-ui'; // Import MenuOption type
import { ThemeType } from '@/api/api-models';
import { NotificationType, useSettings } from './store/useSettings';
import { getVersion } from '@tauri-apps/api/app';
// --- State ---
@@ -28,6 +30,7 @@ const isLoading = ref(true); // Loading state for initial fetch
const errorMsg = ref<string | null>(null); // Error message state
const setting = useSettings();
const currentVersion = await getVersion(); // Fetch current version on mount
// Navigation
const navOptions: MenuOption[] = [ // Explicitly typed
@@ -73,10 +76,10 @@ watch(isStartOnBoot, async (newValue, oldValue) => {
try {
if (newValue) {
await enable();
window.$message.success('已启用开机启动');
//window.$message.success('已启用开机启动');
} else {
await disable();
window.$message.success('已禁用开机启动'); // Provide feedback for disabling too
//window.$message.success('已禁用开机启动'); // Provide feedback for disabling too
}
} catch (err) {
console.error("Failed to update autostart status:", err);
@@ -170,14 +173,16 @@ watch(minimizeOnStart, (newValue) => {
<template v-if="currentTab === 'general'">
<NSpace
vertical
size="large"
>
<NCard
title="启动"
:bordered="false"
>
<NSpace vertical>
<NFormItem
<NFlex
vertical
align="start"
>
<LabelItem
label="开机时启动应用"
label-placement="left"
>
@@ -185,15 +190,19 @@ watch(minimizeOnStart, (newValue) => {
v-model:value="isStartOnBoot"
:disabled="isLoading"
/>
</NFormItem>
<NFormItem
</LabelItem>
<LabelItem
v-if="isStartOnBoot"
label="启动后最小化到托盘"
label-placement="left"
>
<NSwitch v-model:value="minimizeOnStart" />
<NSwitch
v-model:value="setting.settings.bootAsMinimized"
@update:value="setting.save()"
/>
<!-- Add appropriate logic/state for this -->
</NFormItem>
</NSpace>
</LabelItem>
</NFlex>
</NCard>
<NCard
@@ -230,6 +239,10 @@ watch(minimizeOnStart, (newValue) => {
title="通知设置"
:bordered="false"
>
<NAlert type="warning">
暂未完成
</NAlert>
<NDivider />
<NSpace vertical>
<NCheckbox
v-model:checked="setting.settings.enableNotification"
@@ -284,8 +297,42 @@ watch(minimizeOnStart, (newValue) => {
@click="$router.push({name: 'client-test'})"
/>
</template>
<p>应用名称: Your App Name</p>
<p>版本: 1.0.0</p>
<p>VTsuruEventFetcher Tauri</p>
<p>版本: {{ currentVersion }}</p>
<p>
作者:
<NButton
tag="a"
href="https://space.bilibili.com/10021741"
target="_blank"
type="info"
text
>
Megghy
</NButton>
</p>
<p>
储存库:
<NButton
tag="a"
href="https://github.com/Megghy/vtsuru.live/tree/master/src/client"
target="_blank"
type="info"
text
>
界面/逻辑
</NButton>
<NDivider vertical />
<NButton
tag="a"
href="https://github.com/Megghy/vtsuru-fetcher-client"
target="_blank"
type="info"
text
>
Tauri 客户端
</NButton>
</p>
<!-- Add more about info -->
</NCard>
</template>
@@ -312,7 +359,9 @@ watch(minimizeOnStart, (newValue) => {
.fade-leave-to {
opacity: 0;
}
.label-item {
height: 20px;
}
/* Optional: Adjust NFormItem label alignment if needed */
/* :deep(.n-form-item-label) { */
/* Add custom styles */

View File

@@ -7,7 +7,7 @@ import { getBuvid, getRoomKey } from "./utils";
import { initInfo } from "./info";
import { TrayIcon, TrayIconOptions } from '@tauri-apps/api/tray';
import { Menu } from "@tauri-apps/api/menu";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { getCurrentWindow, PhysicalSize } from "@tauri-apps/api/window";
import {
isPermissionGranted,
onAction,
@@ -15,7 +15,7 @@ import {
sendNotification,
} from '@tauri-apps/plugin-notification';
import { openUrl } from "@tauri-apps/plugin-opener";
import { CN_HOST } from "@/data/constants";
import { CN_HOST, isDev } from "@/data/constants";
import { invoke } from "@tauri-apps/api/core";
import { check } from '@tauri-apps/plugin-updater';
import { relaunch } from '@tauri-apps/plugin-process';
@@ -24,10 +24,17 @@ const accountInfo = useAccount();
export const clientInited = ref(false);
let tray: TrayIcon;
export async function initAll() {
export async function initAll(isOnBoot: boolean) {
const setting = useSettings();
if (clientInited.value) {
return;
}
if (isOnBoot) {
if (setting.settings.bootAsMinimized && !isDev) {
const appWindow = getCurrentWindow();
appWindow.hide();
}
}
let permissionGranted = await isPermissionGranted();
checkUpdate();
@@ -96,16 +103,16 @@ export async function initAll() {
case 'DoubleClick':
appWindow.show();
break;
case 'Click':
case 'Click':
appWindow.show();
break;
}
}
}
};
tray = await TrayIcon.new(options);
appWindow.setMinSize(new PhysicalSize(720, 480));
clientInited.value = true;
}
export function OnClientUnmounted() {
@@ -209,7 +216,7 @@ export async function initOpenLive() {
}
return reuslt;
}
function initNotificationHandler(){
function initNotificationHandler() {
onAction((event) => {
if (event.extra?.type === 'question-box') {
openUrl(CN_HOST + '/manage/question-box');

View File

@@ -4,7 +4,7 @@ import { QueryPostAPI } from '@/api/query';
import { OPEN_LIVE_API_URL } from '@/data/constants';
import { error } from '@tauri-apps/plugin-log';
export async function QueryBiliAPI(url: string, method: string = 'GET', cookie: string = '') {
export async function QueryBiliAPI(url: string, method: string = 'GET', cookie: string = '', useCookie: boolean = true) {
const u = new URL(url);
return fetch(url, {
method: method,
@@ -12,8 +12,7 @@ export async function QueryBiliAPI(url: string, method: string = 'GET', cookie:
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36',
Origin: '',
Cookie: cookie || (await useBiliCookie().getBiliCookie()) || '',
'Upgrade-Insecure-Requests': '1',
Cookie: useCookie ? (cookie || (await useBiliCookie().getBiliCookie()) || '') : ''
},
});
}

View File

@@ -7,6 +7,7 @@ export type NotificationSettings = {
export type VTsuruClientSettings = {
useDanmakuClientType: 'openlive' | 'direct';
fallbackToOpenLive: boolean;
bootAsMinimized: boolean;
danmakuHistorySize: number;
loginType: 'qrcode' | 'cookiecloud'
@@ -20,6 +21,7 @@ export const useSettings = defineStore('settings', () => {
const defaultSettings: VTsuruClientSettings = {
useDanmakuClientType: 'openlive',
fallbackToOpenLive: true,
bootAsMinimized: true,
danmakuHistorySize: 100,
loginType: 'qrcode',

1
src/components.d.ts vendored
View File

@@ -15,6 +15,7 @@ declare module 'vue' {
EventFetcherAlert: typeof import('./components/EventFetcherAlert.vue')['default']
EventFetcherStatusCard: typeof import('./components/EventFetcherStatusCard.vue')['default']
FeedbackItem: typeof import('./components/FeedbackItem.vue')['default']
LabelItem: typeof import('./components/LabelItem.vue')['default']
LiveInfoContainer: typeof import('./components/LiveInfoContainer.vue')['default']
MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default']
NAlert: typeof import('naive-ui')['NAlert']

View File

@@ -6,7 +6,21 @@ import { NAlert, NButton, NDivider, NIcon, NTag, NText, NTooltip } from 'naive-u
import { computed } from 'vue'
const accountInfo = useAccount()
const state = accountInfo.value?.eventFetcherState
const state = accountInfo.value?.eventFetcherState
const eventFetcherVersionName = computed(() => {
if (state?.type == EventFetcherType.OBS) {
return 'OBS/网页端'
} else if (state?.type == EventFetcherType.Application) {
return '控制台应用'
} else if (state?.type == EventFetcherType.Server) {
return '本站监听 (已删除)'
} else if (state?.type == EventFetcherType.Tauri) {
return 'Tauri 应用'
} else {
return state?.version ?? '未知'
}
})
const status = computed(() => {
if (state.online == true) {
@@ -57,7 +71,7 @@ const status = computed(() => {
:color="{ borderColor: 'white', textColor: 'white', color: '#4b6159' }"
>
<NIcon :component="FlashCheckmark16Filled" />
{{ state.type == EventFetcherType.OBS ? 'OBS/网页端' : state.version ?? '未知' }}
{{ eventFetcherVersionName }}
</NTag>
</template>
你所使用的版本

View File

@@ -0,0 +1,24 @@
<template>
<span class="label-item">
<p>
{{ label }}
</p>
<slot />
</span>
</template>
<script lang="ts" setup>
defineProps<{
label: string;
}>();
</script>
<style scoped>
.label-item {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
gap: 0.5rem;
}
</style>

View File

@@ -1,19 +1,21 @@
import { defineStore } from 'pinia';
import { ref, computed, shallowRef } from 'vue'; // shallowRef 用于非深度响应对象
import { useLocalStorage } from '@vueuse/core';
import { useRoute } from 'vue-router';
import { compress } from 'brotli-compress';
import { format } from 'date-fns';
import { cookie, useAccount } from '@/api/account';
import { getEventType, recordEvent, streamingInfo } from '@/client/data/info';
import { BASE_HUB_URL, isDev, isTauri } from '@/data/constants';
import BaseDanmakuClient from '@/data/DanmakuClients/BaseDanmakuClient';
import DirectClient, { DirectClientAuthInfo } from '@/data/DanmakuClients/DirectClient';
import OpenLiveClient from '@/data/DanmakuClients/OpenLiveClient';
import * as signalR from '@microsoft/signalr';
import * as msgpack from '@microsoft/signalr-protocol-msgpack';
import { cookie, useAccount } from '@/api/account'; // 假设账户信息路径
import { BASE_HUB_URL, isDev } from '@/data/constants'; // 假设常量路径
import BaseDanmakuClient from '@/data/DanmakuClients/BaseDanmakuClient'; // 假设弹幕客户端基类路径
import DirectClient, { DirectClientAuthInfo } from '@/data/DanmakuClients/DirectClient'; // 假设直连客户端路径
import OpenLiveClient from '@/data/DanmakuClients/OpenLiveClient'; // 假设开放平台客户端路径
import { error as logError, info as logInfo } from '@tauri-apps/plugin-log'; // 使用日志插件
import { getEventType, recordEvent, streamingInfo } from '@/client/data/info';
import { defineStore } from 'pinia';
import { computed, ref, shallowRef } from 'vue'; // shallowRef 用于非深度响应对象
import { useRoute } from 'vue-router';
import { useWebRTC } from './useRTC';
import { QueryBiliAPI } from '@/client/data/utils';
import { platform, type, version } from '@tauri-apps/plugin-os';
import { ZstdCodec, ZstdInit } from '@oneidentity/zstd-js/wasm';
import { encode } from "@msgpack/msgpack";
import { getVersion } from '@tauri-apps/api/app';
export const useWebFetcher = defineStore('WebFetcher', () => {
const route = useRoute();
@@ -24,11 +26,14 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
const state = ref<'disconnected' | 'connecting' | 'connected'>('disconnected'); // SignalR 连接状态
const startedAt = ref<Date>(); // 本次启动时间
const signalRClient = shallowRef<signalR.HubConnection>(); // SignalR 客户端实例 (浅响应)
const signalRId = ref<string>(); // SignalR 连接 ID
const client = shallowRef<BaseDanmakuClient>(); // 弹幕客户端实例 (浅响应)
let timer: any; // 事件发送定时器
let disconnectedByServer = false;
let isFromClient = false; // 是否由Tauri客户端启动
// --- 新增: 详细状态与信息 ---
/** 弹幕客户端内部状态 */
const danmakuClientState = ref<'stopped' | 'connecting' | 'connected'>('stopped'); // 更详细的弹幕客户端状态
@@ -52,6 +57,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
const failedUploads = ref(0);
/** 本次会话发送的总字节数 (压缩后) */
const bytesSentSession = ref(0);
let zstd: ZstdCodec | undefined = undefined; // Zstd 编码器实例 (如果需要压缩)
const prefix = computed(() => isFromClient ? '[web-fetcher-iframe] ' : '[web-fetcher] ');
@@ -64,9 +70,15 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
_isFromClient: boolean = false
): Promise<{ success: boolean; message: string; }> {
if (state.value === 'connected' || state.value === 'connecting') {
logInfo(prefix.value + '已经启动,无需重复启动');
console.log(prefix.value + '已经启动,无需重复启动');
return { success: true, message: '已启动' };
}
try {
zstd ??= await ZstdInit();
} catch (error) {
console.error(prefix.value + '当前浏览器不支持zstd压缩, 回退到原始数据传输');
}
webfetcherType.value = type; // 设置弹幕客户端类型
// 重置会话统计数据
resetSessionStats();
@@ -76,9 +88,9 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
// 使用 navigator.locks 确保同一时间只有一个 Start 操作执行
const result = await navigator.locks.request('webFetcherStartLock', async () => {
logInfo(prefix.value + '开始启动...');
console.log(prefix.value + '开始启动...');
while (!(await connectSignalR())) {
logInfo(prefix.value + '连接 SignalR 失败, 5秒后重试');
console.log(prefix.value + '连接 SignalR 失败, 5秒后重试');
await new Promise((resolve) => setTimeout(resolve, 5000));
// 如果用户手动停止,则退出重试循环
if (state.value === 'disconnected') return { success: false, message: '用户手动停止' };
@@ -86,7 +98,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
let danmakuResult = await connectDanmakuClient(type, directAuthInfo);
while (!danmakuResult?.success) {
logInfo(prefix.value + '弹幕客户端启动失败, 5秒后重试');
console.log(prefix.value + '弹幕客户端启动失败, 5秒后重试');
await new Promise((resolve) => setTimeout(resolve, 5000));
// 如果用户手动停止,则退出重试循环
if (state.value === 'disconnected') return { success: false, message: '用户手动停止' };
@@ -96,7 +108,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
// 只有在两个连接都成功后才设置为 connected
state.value = 'connected';
disconnectedByServer = false;
logInfo(prefix.value + '启动成功');
console.log(prefix.value + '启动成功');
return { success: true, message: '启动成功' };
});
@@ -115,7 +127,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
function Stop() {
if (state.value === 'disconnected') return;
logInfo(prefix.value + '正在停止...');
console.log(prefix.value + '正在停止...');
state.value = 'disconnected'; // 立即设置状态,防止重连逻辑触发
// 清理定时器
@@ -137,7 +149,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
events.length = 0; // 清空事件队列
// resetSessionStats(); // 会话统计在下次 Start 时重置
logInfo(prefix.value + '已停止');
console.log(prefix.value + '已停止');
}
/** 重置会话统计数据 */
@@ -157,11 +169,11 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
directConnectInfo?: DirectClientAuthInfo
) {
if (client.value?.state === 'connected' || client.value?.state === 'connecting') {
logInfo(prefix.value + '弹幕客户端已连接或正在连接');
console.log(prefix.value + '弹幕客户端已连接或正在连接');
return { success: true, message: '弹幕客户端已启动' };
}
logInfo(prefix.value + '正在连接弹幕客户端...');
console.log(prefix.value + '正在连接弹幕客户端...');
danmakuClientState.value = 'connecting';
// 如果实例存在但已停止,先清理
@@ -176,7 +188,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
} else {
if (!directConnectInfo) {
danmakuClientState.value = 'stopped';
logError(prefix.value + '未提供直连弹幕客户端认证信息');
console.error(prefix.value + '未提供直连弹幕客户端认证信息');
return { success: false, message: '未提供弹幕客户端认证信息' };
}
client.value = new DirectClient(directConnectInfo);
@@ -193,13 +205,13 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
const result = await client.value?.Start();
if (result?.success) {
logInfo(prefix.value + '弹幕客户端连接成功, 开始监听弹幕');
console.log(prefix.value + '弹幕客户端连接成功, 开始监听弹幕');
danmakuClientState.value = 'connected'; // 明确设置状态
danmakuServerUrl.value = client.value.serverUrl; // 获取服务器地址
// 启动事件发送定时器 (如果之前没有启动)
timer ??= setInterval(sendEvents, 1500); // 每 1.5 秒尝试发送一次事件
} else {
logError(prefix.value + '弹幕客户端启动失败: ' + result?.message);
console.error(prefix.value + '弹幕客户端启动失败: ' + result?.message);
danmakuClientState.value = 'stopped';
danmakuServerUrl.value = undefined;
client.value = undefined; // 启动失败,清理实例,下次会重建
@@ -212,11 +224,11 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
*/
async function connectSignalR() {
if (signalRClient.value && signalRClient.value.state !== signalR.HubConnectionState.Disconnected) {
logInfo(prefix.value + "SignalR 已连接或正在连接");
console.log(prefix.value + "SignalR 已连接或正在连接");
return true;
}
logInfo(prefix.value + '正在连接到 vtsuru 服务器...');
console.log(prefix.value + '正在连接到 vtsuru 服务器...');
const connection = new signalR.HubConnectionBuilder()
.withUrl(BASE_HUB_URL + 'web-fetcher?token=' + (route.query.token ?? account.value.token), { // 使用 account.token
headers: { Authorization: `Bearer ${cookie.value?.cookie}` },
@@ -229,71 +241,98 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
// --- SignalR 事件监听 ---
connection.onreconnecting(error => {
logInfo(prefix.value + `与服务器断开,正在尝试重连... ${error?.message || ''}`);
console.log(prefix.value + `与服务器断开,正在尝试重连... ${error?.message || ''}`);
state.value = 'connecting'; // 更新状态为连接中
signalRConnectionId.value = undefined; // 连接断开ID失效
});
connection.onreconnected(connectionId => {
logInfo(prefix.value + `与服务器重新连接成功! ConnectionId: ${connectionId}`);
connection.onreconnected(async connectionId => {
console.log(prefix.value + `与服务器重新连接成功! ConnectionId: ${connectionId}`);
signalRConnectionId.value = connectionId ?? undefined;
state.value = 'connected'; // 更新状态为已连接
// 重连成功后可能需要重新发送标识
if (isFromClient) {
connection.send('SetAsVTsuruClient').catch(err => logError(prefix.value + "Send SetAsVTsuruClient failed: " + err));
}
connection.send('Reconnected').catch(err => logError(prefix.value + "Send Reconnected failed: " + err));
signalRId.value = connectionId ?? await sendSelfInfo(connection); // 更新连接ID
connection.send('Reconnected').catch(err => console.error(prefix.value + "Send Reconnected failed: " + err));
});
connection.onclose(async (error) => {
// 只有在不是由 Stop() 或服务器明确要求断开时才记录错误并尝试独立重连(虽然 withAutomaticReconnect 应该处理)
if (state.value !== 'disconnected' && !disconnectedByServer) {
logError(prefix.value + `与服务器连接关闭: ${error?.message || '未知原因'}. 自动重连将处理.`);
console.error(prefix.value + `与服务器连接关闭: ${error?.message || '未知原因'}. 自动重连将处理.`);
state.value = 'connecting'; // 标记为连接中,等待自动重连
signalRConnectionId.value = undefined;
// withAutomaticReconnect 会处理重连,这里不需要手动调用 reconnect
} else if (disconnectedByServer) {
logInfo(prefix.value + `连接已被服务器关闭.`);
console.log(prefix.value + `连接已被服务器关闭.`);
Stop(); // 服务器要求断开,则彻底停止
} else {
logInfo(prefix.value + `连接已手动关闭.`);
console.log(prefix.value + `连接已手动关闭.`);
}
});
connection.on('Disconnect', (reason: unknown) => {
logInfo(prefix.value + '被服务器断开连接: ' + reason);
console.log(prefix.value + '被服务器断开连接: ' + reason);
disconnectedByServer = true; // 标记是服务器主动断开
Stop(); // 服务器要求断开,调用 Stop 清理所有资源
});
connection.on('Request', async (url: string, method: string, body: string, useCookie: boolean) => onRequest(url, method, body, useCookie));
// --- 尝试启动连接 ---
try {
await connection.start();
logInfo(prefix.value + '已连接到 vtsuru 服务器, ConnectionId: ' + connection.connectionId);
console.log(prefix.value + '已连接到 vtsuru 服务器, ConnectionId: ' + connection.connectionId); // 调试输出连接状态
signalRConnectionId.value = connection.connectionId ?? undefined; // 保存连接ID
signalRId.value = await sendSelfInfo(connection); // 发送客户端信息
await connection.send('Finished'); // 通知服务器已准备好
if (isFromClient) {
await connection.send('SetAsVTsuruClient'); // 如果是客户端,发送标识
}
signalRClient.value = connection; // 保存实例
// state.value = 'connected'; // 状态将在 Start 函数末尾统一设置
return true;
} catch (e) {
logError(prefix.value + '无法连接到 vtsuru 服务器: ' + e);
console.error(prefix.value + '无法连接到 vtsuru 服务器: ' + e);
signalRConnectionId.value = undefined;
signalRClient.value = undefined;
// state.value = 'disconnected'; // 保持 connecting 或由 Start 控制
return false;
}
}
async function sendSelfInfo(client: signalR.HubConnection) {
return client.invoke('SetSelfInfo',
isTauri ? `tauri ${platform()} ${version()}` : navigator.userAgent,
isTauri ? 'tauri' : 'web',
isTauri ? await getVersion() : '1.0.0',
webfetcherType.value === 'direct');
}
type ResponseFetchRequestData = {
Message: string;
Success: boolean;
Data: string;
};
async function onRequest(url: string, method: string, body: string, useCookie: boolean) {
if (!isTauri) {
console.error(prefix.value + '非Tauri环境下无法处理请求: ' + url);
return {
Message: '非Tauri环境',
Success: false,
Data: ''
};
}
const result = await QueryBiliAPI(url, method, body, useCookie);
console.log(`${prefix.value}请求 (${method})${url}: `, result.statusText);
if (result.ok) {
const data = await result.text();
return {
Message: '请求成功',
Success: true,
Data: data
} as ResponseFetchRequestData;
}
}
// async function reconnect() { // withAutomaticReconnect 存在时,此函数通常不需要手动调用
// if (disconnectedByServer || state.value === 'disconnected') return;
// logInfo(prefix.value + '尝试手动重连...');
// console.log(prefix.value + '尝试手动重连...');
// try {
// await signalRClient.value?.start();
// logInfo(prefix.value + '手动重连成功');
// console.log(prefix.value + '手动重连成功');
// signalRConnectionId.value = signalRClient.value?.connectionId ?? null;
// state.value = 'connected';
// if (isFromClient) {
@@ -301,7 +340,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
// }
// await signalRClient.value?.send('Reconnected');
// } catch (err) {
// logError(prefix.value + '手动重连失败: ' + err);
// console.error(prefix.value + '手动重连失败: ' + err);
// setTimeout(reconnect, 10000); // 失败后10秒再次尝试
// }
// }
@@ -351,20 +390,34 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
try {
const result = await signalRClient.value.invoke<{ Success: boolean; Message: string; }>(
'UploadEvents', batch, webfetcherType.value === 'direct'? true : false
);
let result: { Success: boolean; Message: string; } = { Success: false, Message: '' };
let length = 0;
let eventCharLength = batch.map(event => event.length).reduce((a, b) => a + b, 0); // 计算字符长度
if (zstd && eventCharLength > 100) {
const data = zstd.ZstdSimple.compress(encode(batch), 11);
length = data.length;
result = await signalRClient.value.invoke<{ Success: boolean; Message: string; }>(
'UploadEventsCompressedV2', data
);
}
else {
length = new TextEncoder().encode(batch.join()).length;
result = await signalRClient.value.invoke<{ Success: boolean; Message: string; }>(
'UploadEvents', batch, webfetcherType.value === 'direct' ? true : false
);
}
if (result?.Success) {
events.splice(0, batch.length); // 从队列中移除已成功发送的事件
successfulUploads.value++;
bytesSentSession.value += new TextEncoder().encode(batch.join()).length;
bytesSentSession.value += length;
} else {
failedUploads.value++;
logError(prefix.value + '上传弹幕失败: ' + result?.Message);
console.error(prefix.value + '上传弹幕失败: ' + result?.Message);
}
} catch (err) {
failedUploads.value++;
logError(prefix.value + '发送事件时出错: ' + err);
console.error(prefix.value + '发送事件时出错: ' + err);
}
}
@@ -379,6 +432,7 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
startedAt,
isStreaming: computed(() => streamingInfo.value?.status === 'streaming'), // 从 statistics 模块获取
webfetcherType,
signalRId,
// 连接详情
danmakuClientState,