mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-06 18:36:55 +08:00
添加客户端
This commit is contained in:
3
default.d.ts
vendored
3
default.d.ts
vendored
@@ -1,5 +1,5 @@
|
||||
|
||||
import type { LoadingBarProviderInst, MessageProviderInst, ModalProviderInst } from 'naive-ui'
|
||||
import type { LoadingBarProviderInst, MessageProviderInst, ModalProviderInst, NotificationProviderInst } from 'naive-ui'
|
||||
import type { useRoute } from 'vue-router'
|
||||
|
||||
declare module 'vue3-aplayer' {
|
||||
@@ -22,5 +22,6 @@ declare global {
|
||||
$route: ReturnType<typeof useRoute>
|
||||
$modal: ModalProviderInst
|
||||
$mitt: Emitter<MittType>
|
||||
$notification: NotificationProviderInst
|
||||
}
|
||||
}
|
||||
|
||||
10
package.json
10
package.json
@@ -16,7 +16,16 @@
|
||||
"@microsoft/signalr-protocol-msgpack": "^8.0.7",
|
||||
"@mixer/postmessage-rpc": "^1.1.4",
|
||||
"@tauri-apps/api": "^2.4.0",
|
||||
"@tauri-apps/plugin-autostart": "^2.3.0",
|
||||
"@tauri-apps/plugin-http": "^2.4.2",
|
||||
"@tauri-apps/plugin-log": "^2.3.1",
|
||||
"@tauri-apps/plugin-notification": "^2.2.2",
|
||||
"@tauri-apps/plugin-opener": "^2.2.6",
|
||||
"@tauri-apps/plugin-os": "^2.2.1",
|
||||
"@tauri-apps/plugin-process": "^2.2.1",
|
||||
"@tauri-apps/plugin-store": "^2.2.0",
|
||||
"@tauri-apps/plugin-updater": "^2.7.0",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.27.0",
|
||||
"@vicons/fluent": "^0.13.0",
|
||||
"@vitejs/plugin-basic-ssl": "^2.0.0",
|
||||
@@ -28,6 +37,7 @@
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"bilibili-live-ws": "^6.3.1",
|
||||
"brotli-compress": "^1.3.3",
|
||||
"crypto-js": "^4.2.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"easy-speech": "^2.4.0",
|
||||
"echarts": "^5.6.0",
|
||||
|
||||
340
src/App.vue
340
src/App.vue
@@ -1,75 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import ManageLayout from '@/views/ManageLayout.vue'
|
||||
import ViewerLayout from '@/views/ViewerLayout.vue'
|
||||
import {
|
||||
dateZhCN,
|
||||
NConfigProvider,
|
||||
NDialogProvider,
|
||||
NElement,
|
||||
NLayoutContent,
|
||||
NLoadingBarProvider,
|
||||
NMessageProvider,
|
||||
NModalProvider,
|
||||
NNotificationProvider,
|
||||
NSpin,
|
||||
zhCN,
|
||||
} from 'naive-ui'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import TempComponent from './components/TempComponent.vue'
|
||||
import { isDarkMode, theme } from './Utils'
|
||||
import OBSLayout from './views/OBSLayout.vue'
|
||||
import OpenLiveLayout from './views/OpenLiveLayout.vue'
|
||||
import { ThemeType } from './api/api-models';
|
||||
import ManageLayout from '@/views/ManageLayout.vue';
|
||||
import ViewerLayout from '@/views/ViewerLayout.vue';
|
||||
import {
|
||||
dateZhCN,
|
||||
NConfigProvider,
|
||||
NDialogProvider,
|
||||
NElement,
|
||||
NLayoutContent,
|
||||
NLoadingBarProvider,
|
||||
NMessageProvider,
|
||||
NModalProvider,
|
||||
NNotificationProvider,
|
||||
NSpin,
|
||||
zhCN,
|
||||
} from 'naive-ui';
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import TempComponent from './components/TempComponent.vue';
|
||||
import { isDarkMode, theme } from './Utils';
|
||||
import OBSLayout from './views/OBSLayout.vue';
|
||||
import OpenLiveLayout from './views/OpenLiveLayout.vue';
|
||||
import ClientLayout from './client/ClientLayout.vue';
|
||||
import { ThemeType } from './api/api-models';
|
||||
|
||||
const route = useRoute()
|
||||
const themeType = useStorage('Settings.Theme', ThemeType.Auto)
|
||||
const route = useRoute();
|
||||
const themeType = useStorage('Settings.Theme', ThemeType.Auto);
|
||||
|
||||
const layout = computed(() => {
|
||||
if (route.path.startsWith('/user') || route.name == 'user' || route.path.startsWith('/@')) {
|
||||
document.title = `${route.meta.title} · ${route.params.id} · VTsuru`
|
||||
return 'viewer'
|
||||
}
|
||||
else if (route.path.startsWith('/manage')) {
|
||||
document.title = `${route.meta.title} · 管理 · VTsuru`
|
||||
return 'manage'
|
||||
}
|
||||
else if (route.path.startsWith('/open-live')) {
|
||||
document.title = `${route.meta.title} · 开放平台 · VTsuru`
|
||||
return 'open-live'
|
||||
}
|
||||
else if (route.path.startsWith('/obs')) {
|
||||
document.title = `${route.meta.title} · OBS · VTsuru`
|
||||
return 'obs'
|
||||
}
|
||||
else {
|
||||
document.title = `${route.meta.title} · VTsuru`
|
||||
return ''
|
||||
}
|
||||
})
|
||||
watchEffect(() => {
|
||||
if (isDarkMode.value) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
});
|
||||
const layout = computed(() => {
|
||||
if (route.path.startsWith('/user') || route.name == 'user' || route.path.startsWith('/@')) {
|
||||
document.title = `${route.meta.title} · ${route.params.id} · VTsuru`;
|
||||
return 'viewer';
|
||||
}
|
||||
else if (route.path.startsWith('/manage')) {
|
||||
document.title = `${route.meta.title} · 管理 · VTsuru`;
|
||||
return 'manage';
|
||||
}
|
||||
else if (route.path.startsWith('/open-live')) {
|
||||
document.title = `${route.meta.title} · 开放平台 · VTsuru`;
|
||||
return 'open-live';
|
||||
}
|
||||
else if (route.path.startsWith('/obs')) {
|
||||
document.title = `${route.meta.title} · OBS · VTsuru`;
|
||||
return 'obs';
|
||||
}
|
||||
else if (route.path.startsWith('/client')) {
|
||||
document.title = `${route.meta.title} · 客户端 · VTsuru`;
|
||||
return 'client';
|
||||
}
|
||||
else {
|
||||
document.title = `${route.meta.title} · VTsuru`;
|
||||
return '';
|
||||
}
|
||||
});
|
||||
watchEffect(() => {
|
||||
if (isDarkMode.value) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
});
|
||||
|
||||
const themeOverrides = {
|
||||
common: {
|
||||
// primaryColor: '#9ddddc',
|
||||
fontFamily:
|
||||
'Inter ,"Noto Sans SC",-apple-system,blinkmacsystemfont,"Segoe UI",roboto,"Helvetica Neue",arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"!important',
|
||||
},
|
||||
// ...
|
||||
}
|
||||
const themeOverrides = {
|
||||
common: {
|
||||
// primaryColor: '#9ddddc',
|
||||
fontFamily:
|
||||
'Inter ,"Noto Sans SC",-apple-system,blinkmacsystemfont,"Segoe UI",roboto,"Helvetica Neue",arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"!important',
|
||||
},
|
||||
// ...
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (isDarkMode.value) {
|
||||
document.documentElement.classList.add('dark');
|
||||
console.log('Added dark class to HTML'); // For debugging
|
||||
}
|
||||
})
|
||||
onMounted(() => {
|
||||
if (isDarkMode.value) {
|
||||
document.documentElement.classList.add('dark');
|
||||
console.log('Added dark class to HTML'); // For debugging
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -93,6 +98,7 @@ onMounted(() => {
|
||||
<ManageLayout v-else-if="layout == 'manage'" />
|
||||
<OpenLiveLayout v-else-if="layout == 'open-live'" />
|
||||
<OBSLayout v-else-if="layout == 'obs'" />
|
||||
<ClientLayout v-else-if="layout == 'client'" />
|
||||
<template v-else>
|
||||
<RouterView />
|
||||
</template>
|
||||
@@ -115,129 +121,129 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
font-feature-settings: 'liga' 1, 'calt' 1;
|
||||
--vtsuru-header-height: 50px;
|
||||
--vtsuru-content-padding: 16px;
|
||||
}
|
||||
|
||||
@supports (font-variation-settings: normal) {
|
||||
:root {
|
||||
font-family: InterVariable, sans-serif;
|
||||
font-feature-settings: 'liga' 1, 'calt' 1;
|
||||
--vtsuru-header-height: 50px;
|
||||
--vtsuru-content-padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 进入和离开过渡的样式 */
|
||||
.v-enter-from,
|
||||
.v-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
@supports (font-variation-settings: normal) {
|
||||
:root {
|
||||
font-family: InterVariable, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
/* 离开和进入过程中的样式 */
|
||||
.v-enter-active,
|
||||
.v-leave-active {
|
||||
/* 添加过渡动画 */
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
/* 进入之后和离开之前的样式 */
|
||||
.v-enter-to,
|
||||
.v-leave-from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.bounce-enter-active {
|
||||
animation: bounce 0.3s;
|
||||
}
|
||||
|
||||
.bounce-leave-active {
|
||||
animation: bounce 0.3s reverse;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
/* 进入和离开过渡的样式 */
|
||||
.v-enter-from,
|
||||
.v-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
60% {
|
||||
transform: scale(1.1);
|
||||
/* 离开和进入过程中的样式 */
|
||||
.v-enter-active,
|
||||
.v-leave-active {
|
||||
/* 添加过渡动画 */
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
/* 进入之后和离开之前的样式 */
|
||||
.v-enter-to,
|
||||
.v-leave-from {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.bounce-enter-active {
|
||||
animation: bounce 0.3s;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
.bounce-leave-active {
|
||||
animation: bounce 0.3s reverse;
|
||||
}
|
||||
|
||||
.scale-enter-active,
|
||||
.scale-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
@keyframes bounce {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.scale-enter-from,
|
||||
.scale-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: all 0.5s ease-out;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.slide-enter-to {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-enter-from {
|
||||
position: absolute;
|
||||
right: -100%;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-leave-to {
|
||||
position: absolute;
|
||||
left: -100%;
|
||||
}
|
||||
.scale-enter-active,
|
||||
.scale-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-leave-from {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
.scale-enter-from,
|
||||
.scale-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: all 0.5s ease-out;
|
||||
}
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: all 0.5s ease-out;
|
||||
}
|
||||
|
||||
.slide-up-enter-to {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
.slide-enter-to {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.slide-up-enter-from {
|
||||
position: absolute;
|
||||
top: -100%;
|
||||
}
|
||||
.slide-enter-from {
|
||||
position: absolute;
|
||||
right: -100%;
|
||||
}
|
||||
|
||||
.slide-up-leave-to {
|
||||
position: absolute;
|
||||
bottom: -100%;
|
||||
}
|
||||
.slide-leave-to {
|
||||
position: absolute;
|
||||
left: -100%;
|
||||
}
|
||||
|
||||
.slide-up-leave-from {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
}
|
||||
.slide-leave-from {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: all 0.5s ease-out;
|
||||
}
|
||||
|
||||
.slide-up-enter-to {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.slide-up-enter-from {
|
||||
position: absolute;
|
||||
top: -100%;
|
||||
}
|
||||
|
||||
.slide-up-leave-to {
|
||||
position: absolute;
|
||||
bottom: -100%;
|
||||
}
|
||||
|
||||
.slide-up-leave-from {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,19 +5,20 @@ import { isSameDay } from 'date-fns'
|
||||
import { createDiscreteApi } from 'naive-ui'
|
||||
import { ref } from 'vue'
|
||||
import { APIRoot, AccountInfo, FunctionTypes } from './api-models'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
export const ACCOUNT = ref<AccountInfo>({} as AccountInfo)
|
||||
export const isLoadingAccount = ref(true)
|
||||
const route = useRoute()
|
||||
export const isLoggedIn = computed<boolean>(() => {
|
||||
return ACCOUNT.value.id > 0
|
||||
})
|
||||
|
||||
const { message } = createDiscreteApi(['message'])
|
||||
const cookie = useLocalStorage('JWT_Token', '')
|
||||
const cookieRefreshDate = useLocalStorage('JWT_Token_Last_Refresh', Date.now())
|
||||
const cookieRefreshDate = useLocalStorage('JWT_Token_Last_Refresh', 0)
|
||||
|
||||
export async function GetSelfAccount() {
|
||||
if (cookie.value) {
|
||||
const result = await Self()
|
||||
export async function GetSelfAccount(token?: string) {
|
||||
if (cookie.value || token) {
|
||||
const result = await Self(token)
|
||||
if (result.code == 200) {
|
||||
if (!ACCOUNT.value.id) {
|
||||
ACCOUNT.value = result.data
|
||||
@@ -28,7 +29,7 @@ export async function GetSelfAccount() {
|
||||
isLoadingAccount.value = false
|
||||
//console.log('[vtsuru] 已获取账户信息')
|
||||
if (!isSameDay(new Date(), cookieRefreshDate.value)) {
|
||||
refreshCookie()
|
||||
refreshCookie(token)
|
||||
}
|
||||
return result.data
|
||||
} else if (result.code == 401) {
|
||||
@@ -45,16 +46,17 @@ export async function GetSelfAccount() {
|
||||
}
|
||||
isLoadingAccount.value = false
|
||||
}
|
||||
|
||||
export function UpdateAccountLoop() {
|
||||
setInterval(() => {
|
||||
if (ACCOUNT.value && route?.name != 'question-display') {
|
||||
if (ACCOUNT.value && window.$route?.name != 'question-display') {
|
||||
// 防止在问题详情页刷新
|
||||
GetSelfAccount()
|
||||
}
|
||||
}, 60 * 1000)
|
||||
}
|
||||
function refreshCookie() {
|
||||
QueryPostAPI<string>(`${ACCOUNT_API_URL}refresh-token`).then((data) => {
|
||||
function refreshCookie(token?: string) {
|
||||
QueryPostAPIWithParams<string>(`${ACCOUNT_API_URL}refresh-token`, { token }).then((data) => {
|
||||
if (data.code == 200) {
|
||||
cookie.value = data.data
|
||||
cookieRefreshDate.value = Date.now()
|
||||
@@ -155,8 +157,8 @@ export async function Login(
|
||||
password
|
||||
})
|
||||
}
|
||||
export async function Self(): Promise<APIRoot<AccountInfo>> {
|
||||
return QueryPostAPI<AccountInfo>(`${ACCOUNT_API_URL}self`)
|
||||
export async function Self(token?: string): Promise<APIRoot<AccountInfo>> {
|
||||
return QueryPostAPIWithParams<AccountInfo>(`${ACCOUNT_API_URL}self`, token ? { token } : undefined)
|
||||
}
|
||||
export async function AddBiliBlackList(
|
||||
id: number,
|
||||
|
||||
1113
src/client/ClientFetcher.vue
Normal file
1113
src/client/ClientFetcher.vue
Normal file
File diff suppressed because it is too large
Load Diff
143
src/client/ClientIndex.vue
Normal file
143
src/client/ClientIndex.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
import { useAccount } from '@/api/account';
|
||||
import { useWebFetcher } from '@/store/useWebFetcher';
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import { useElementSize } from '@vueuse/core';
|
||||
import { roomInfo, streamingInfo } from './data/info';
|
||||
|
||||
const accountInfo = useAccount();
|
||||
const cookie = useLocalStorage('JWT_Token', '')
|
||||
|
||||
const webfetcher = useWebFetcher();
|
||||
|
||||
const coverRef = ref();
|
||||
const { width, height } = useElementSize(coverRef);
|
||||
|
||||
function logout() {
|
||||
cookie.value = undefined;
|
||||
window.location.reload();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NFlex
|
||||
justify="center"
|
||||
align="center"
|
||||
gap="large"
|
||||
>
|
||||
<NCard
|
||||
title="首页"
|
||||
embedded
|
||||
size="small"
|
||||
>
|
||||
<div>
|
||||
你好, {{ accountInfo.name }}
|
||||
</div>
|
||||
</NCard>
|
||||
<NCard
|
||||
title="账号"
|
||||
embedded
|
||||
>
|
||||
<div>
|
||||
<NFlex
|
||||
align="center"
|
||||
gap="medium"
|
||||
>
|
||||
<NAvatar
|
||||
:src="`${accountInfo.streamerInfo?.faceUrl}@100w`"
|
||||
:fallback-src="accountInfo.name[.2]"
|
||||
bordered
|
||||
round
|
||||
:img-props="{ referrerpolicy: 'no-referrer' }"
|
||||
/>
|
||||
<NFlex
|
||||
vertical
|
||||
size="small"
|
||||
>
|
||||
<NFlex
|
||||
align="center"
|
||||
gap="small"
|
||||
>
|
||||
<NText
|
||||
strong
|
||||
style="font-size: 1.25rem;"
|
||||
>
|
||||
{{ accountInfo.name }}
|
||||
</NText>
|
||||
<NText depth="3">
|
||||
({{ accountInfo.streamerInfo?.name }})
|
||||
</NText>
|
||||
</NFlex>
|
||||
<NText depth="3">
|
||||
{{ accountInfo.bindEmail }}
|
||||
</NText>
|
||||
</NFlex>
|
||||
</NFlex>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div style="display: flex; align-items: flex-end; gap: 0.5rem;">
|
||||
<NPopconfirm @positive-click="logout">
|
||||
<template #trigger>
|
||||
<NButton type="error">
|
||||
退出登录
|
||||
</NButton>
|
||||
</template>
|
||||
确定要登出吗? B站 Cookie 将需要重新扫描
|
||||
</NPopconfirm>
|
||||
</div>
|
||||
</template>
|
||||
</NCard>
|
||||
<NCard>
|
||||
<template #header>
|
||||
<NSpace align="center">
|
||||
直播状态
|
||||
<NTag
|
||||
:type="!accountInfo.streamerInfo?.isStreaming ? 'error' : 'success'"
|
||||
>
|
||||
{{ !accountInfo.streamerInfo?.isStreaming ? '未直播' : '直播中' }}
|
||||
</NTag>
|
||||
</NSpace>
|
||||
<NText
|
||||
depth="3"
|
||||
style="font-size: 14px"
|
||||
>
|
||||
当前的直播间信息
|
||||
</NText>
|
||||
</template>
|
||||
<div
|
||||
v-if="roomInfo?.user_cover"
|
||||
style="position: relative"
|
||||
>
|
||||
<div
|
||||
style="position: relative; width: 100%; max-width: 500px;"
|
||||
>
|
||||
<NImage
|
||||
ref="coverRef"
|
||||
:src="roomInfo?.user_cover"
|
||||
style="width: 100%; opacity: 0.5; border-radius: 8px;"
|
||||
referrerpolicy="no-referrer"
|
||||
:img-props="{ referrerpolicy: 'no-referrer', style: { width: '100%'} }"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style="position: absolute; z-index: 1; top: 0; width: 100%; background: linear-gradient(to top, rgba(0,0,0,0.8), rgba(0,0,0,0.2), transparent)"
|
||||
:style="{ height: `${height}px`, maxWidth: '500px', cursor: 'pointer' }"
|
||||
@click="openUrl(`https://live.bilibili.com/${accountInfo.biliRoomId}`)"
|
||||
/>
|
||||
<NText style="position: absolute; bottom: 12px; left: 16px; z-index: 2; color: white; font-size: 18px">
|
||||
{{ roomInfo?.title }}
|
||||
</NText>
|
||||
</div>
|
||||
<template #footer>
|
||||
<NSpace align="end">
|
||||
<NButton
|
||||
type="primary"
|
||||
@click="openUrl(`https://live.bilibili.com/${accountInfo.biliRoomId}`)"
|
||||
>
|
||||
前往直播间
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NCard>
|
||||
</NFlex>
|
||||
</template>
|
||||
434
src/client/ClientLayout.vue
Normal file
434
src/client/ClientLayout.vue
Normal file
@@ -0,0 +1,434 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, h, computed } from 'vue'; // 引入 ref, h, computed
|
||||
import { RouterLink, RouterView } from 'vue-router'; // 引入 Vue Router 组件
|
||||
|
||||
// 引入 Naive UI 组件 和 图标
|
||||
import { NA, NButton, NCard, NInput, NLayout, NLayoutSider, NLayoutContent, NMenu, NSpace, NSpin, NText, NTooltip } from 'naive-ui';
|
||||
import { CheckmarkCircle, CloseCircle, Home } from '@vicons/ionicons5';
|
||||
|
||||
// 引入 Tauri 插件
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
|
||||
// 引入自定义 API 和状态管理
|
||||
import { ACCOUNT, GetSelfAccount, isLoadingAccount, isLoggedIn, useAccount } from '@/api/account';
|
||||
import { useWebFetcher } from '@/store/useWebFetcher';
|
||||
|
||||
// 引入子组件
|
||||
import WindowBar from './WindowBar.vue';
|
||||
import { initAll, OnClientUnmounted } from './data/initialize';
|
||||
import { CloudArchive24Filled, Settings24Filled } from '@vicons/fluent';
|
||||
|
||||
// --- 响应式状态 ---
|
||||
|
||||
// 获取 webfetcher 状态管理的实例
|
||||
const webfetcher = useWebFetcher();
|
||||
// 获取账户信息状态管理的实例 (如果 accountInfo 未使用,可以考虑移除)
|
||||
const accountInfo = useAccount();
|
||||
// 用于存储用户输入的 Token
|
||||
const token = ref('');
|
||||
|
||||
// --- 计算属性 ---
|
||||
// (这里没有显式的计算属性,但 isLoggedIn 本身可能是一个来自 account 模块的计算属性)
|
||||
|
||||
// --- 方法 ---
|
||||
|
||||
/**
|
||||
* @description 处理用户登录逻辑
|
||||
*/
|
||||
async function login() {
|
||||
// 校验 Token 是否为空
|
||||
if (!token.value.trim()) {
|
||||
window.$message.error('请输入 Token'); // 使用全局消息提示
|
||||
return;
|
||||
}
|
||||
|
||||
isLoadingAccount.value = true; // 开始加载状态
|
||||
try {
|
||||
// 调用 API 获取账户信息
|
||||
const result = await GetSelfAccount(token.value.trim());
|
||||
|
||||
// 处理 API 返回结果
|
||||
if (!result) {
|
||||
// 登录失败:无效 Token
|
||||
window.$notification.error({ // 使用全局通知
|
||||
title: '登陆失败',
|
||||
content: '无效的Token',
|
||||
duration: 3000
|
||||
});
|
||||
} else {
|
||||
// 检查 B站主播码是否绑定
|
||||
if (!result.isBiliAuthed) {
|
||||
window.$notification.error({
|
||||
title: '登陆失败',
|
||||
content: 'B站主播码未绑定, 请先在网站管理页进行绑定',
|
||||
duration: 3000
|
||||
});
|
||||
} else {
|
||||
// 登录成功
|
||||
window.$message.success('登陆成功');
|
||||
ACCOUNT.value = result; // 更新全局账户信息
|
||||
// isLoadingAccount.value = false; // 状态在 finally 中统一处理
|
||||
initAll(); // 初始化 WebFetcher
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 处理请求过程中的意外错误
|
||||
console.error("Login failed:", error);
|
||||
window.$notification.error({
|
||||
title: '登陆出错',
|
||||
content: '发生未知错误,请稍后再试或联系管理员。',
|
||||
duration: 3000
|
||||
});
|
||||
} finally {
|
||||
// 无论成功或失败,最终都结束加载状态
|
||||
isLoadingAccount.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 导航菜单配置 ---
|
||||
// 将菜单项定义为常量,使模板更清晰
|
||||
const menuOptions = [
|
||||
{
|
||||
label: () =>
|
||||
h(RouterLink, { to: { name: 'client-index' } }, () => '主页'), // 使用 h 函数渲染 RouterLink
|
||||
key: 'go-back-home',
|
||||
icon: () => h(Home)
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(RouterLink, { to: { name: 'client-fetcher' } }, () => 'EventFetcher'),
|
||||
key: 'fetcher',
|
||||
icon: () => h(CloudArchive24Filled)
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(RouterLink, { to: { name: 'client-settings' } }, () => '设置'),
|
||||
key: 'settings',
|
||||
icon: () => h(Settings24Filled)
|
||||
},
|
||||
];
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('beforeunload', (event) => {
|
||||
OnClientUnmounted(); // 调用清理函数
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WindowBar />
|
||||
|
||||
<div
|
||||
v-if="!isLoggedIn"
|
||||
class="login-container"
|
||||
>
|
||||
<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>
|
||||
|
||||
<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()"
|
||||
>
|
||||
<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>
|
||||
|
||||
<NMenu
|
||||
:options="menuOptions"
|
||||
:default-value="'go-back-home'"
|
||||
class="sider-menu"
|
||||
/>
|
||||
</div>
|
||||
</NLayoutSider>
|
||||
|
||||
<NLayoutContent
|
||||
class="main-layout-content"
|
||||
:native-scrollbar="false"
|
||||
>
|
||||
<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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
/* 登录容器样式 */
|
||||
.login-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* 计算高度,减去 WindowBar 的高度 (假设为 30px) */
|
||||
height: calc(100vh - 30px);
|
||||
background-color: #f8f8fa;
|
||||
/* 可选:添加背景色 */
|
||||
}
|
||||
|
||||
/* 登录卡片样式 */
|
||||
.login-card {
|
||||
max-width: 90vw;
|
||||
/* 限制最大宽度 */
|
||||
width: 400px;
|
||||
/* 固定或最大宽度,根据设计调整 */
|
||||
}
|
||||
|
||||
/* 登录卡片头部样式 */
|
||||
.login-header {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* 登录标题 */
|
||||
.login-title {
|
||||
font-size: 1.5rem;
|
||||
line-height: 2rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
/* 居中标题 */
|
||||
margin-bottom: 0.5rem;
|
||||
/* 标题和副标题间距 */
|
||||
}
|
||||
|
||||
/* 登录副标题 */
|
||||
.login-subtitle {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: rgb(107, 114, 128);
|
||||
text-align: center;
|
||||
/* 居中副标题 */
|
||||
}
|
||||
|
||||
/* Token 输入框标签容器 */
|
||||
.token-label-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
/* 让 "Token" 和 "前往获取" 分散对齐 */
|
||||
margin-bottom: 0.5rem;
|
||||
/* 标签和输入框间距 */
|
||||
}
|
||||
|
||||
/* Token 标签 */
|
||||
.token-label {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
/* "前往获取" 链接样式 */
|
||||
.token-get-link {
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
margin-left: 8px;
|
||||
/* 与左侧标签保持一点距离 */
|
||||
}
|
||||
|
||||
/* 主布局样式 */
|
||||
.main-layout {
|
||||
/* 计算高度,减去 WindowBar 的高度 (假设为 30px) */
|
||||
height: calc(100vh - 30px);
|
||||
}
|
||||
|
||||
/* 侧边栏内容容器 (用于可能的滚动或内边距) */
|
||||
.sider-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
||||
/* 侧边栏头部样式 */
|
||||
.sider-header {
|
||||
height: 60px;
|
||||
/* 固定高度 */
|
||||
border-bottom: 1px solid var(--n-border-color);
|
||||
/* 使用 Naive UI 的边框颜色变量 */
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
/* 让标题和图标分开 */
|
||||
flex-shrink: 0;
|
||||
/* 防止在 flex 布局中被压缩 */
|
||||
}
|
||||
|
||||
/* 应用标题样式 */
|
||||
.app-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
/* 稍微调整字体大小 */
|
||||
}
|
||||
|
||||
/* Fetcher 状态按钮样式 */
|
||||
.fetcher-status-button {
|
||||
padding: 0 6px;
|
||||
/* 调整按钮内边距 */
|
||||
}
|
||||
|
||||
/* Fetcher 状态图标通用样式 */
|
||||
.fetcher-status-icon {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
vertical-align: middle;
|
||||
/* 图标垂直居中 */
|
||||
}
|
||||
|
||||
/* 连接成功图标颜色 */
|
||||
.fetcher-status-icon.connected {
|
||||
color: rgb(132, 204, 22);
|
||||
}
|
||||
|
||||
/* 连接失败/断开图标颜色 */
|
||||
.fetcher-status-icon.disconnected {
|
||||
color: rgb(190, 18, 60);
|
||||
}
|
||||
|
||||
/* 侧边栏菜单样式 */
|
||||
.sider-menu {
|
||||
flex-grow: 1;
|
||||
/* 让菜单占据剩余空间 */
|
||||
padding-top: 1rem;
|
||||
/* 菜单与顶部的间距 */
|
||||
}
|
||||
|
||||
/* Suspense 后备内容样式 */
|
||||
.suspense-fallback {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: calc(100vh - 30px - 2rem);
|
||||
/* 大致计算高度 */
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 路由切换动画 */
|
||||
.fade-slide-enter-active,
|
||||
.fade-slide-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-slide-enter-from,
|
||||
.fade-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
</style>
|
||||
320
src/client/ClientSettings.vue
Normal file
320
src/client/ClientSettings.vue
Normal file
@@ -0,0 +1,320 @@
|
||||
<script setup lang="ts">
|
||||
import { useColorMode } from '@vueuse/core';
|
||||
import { disable, enable, isEnabled } from "@tauri-apps/plugin-autostart";
|
||||
import { ref, watch, onMounted } from 'vue';
|
||||
import {
|
||||
NGrid,
|
||||
NGridItem, // Corrected import NGridItem
|
||||
NMenu,
|
||||
NRadio,
|
||||
NRadioGroup, // Added NRadioGroup
|
||||
NSwitch,
|
||||
NSpace,
|
||||
NCard,
|
||||
NSpin, // Added NSpin for loading state
|
||||
NFormItem, // Added NFormItem
|
||||
NAlert,
|
||||
NCheckboxGroup,
|
||||
NCheckbox, // 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';
|
||||
|
||||
// --- State ---
|
||||
|
||||
const currentTab = ref('general');
|
||||
const isLoading = ref(true); // Loading state for initial fetch
|
||||
const errorMsg = ref<string | null>(null); // Error message state
|
||||
|
||||
const setting = useSettings();
|
||||
|
||||
// Navigation
|
||||
const navOptions: MenuOption[] = [ // Explicitly typed
|
||||
{ label: '常规', key: 'general' },
|
||||
{ label: '通知', key: 'notification' },
|
||||
{ label: '其他', key: 'other' },
|
||||
{ label: '关于', key: 'about' },
|
||||
];
|
||||
|
||||
// Theme
|
||||
|
||||
const themeType = useStorage('Settings.Theme', ThemeType.Auto);
|
||||
|
||||
// Autostart Settings
|
||||
const isStartOnBoot = ref(false); // Initialize with default, fetch in onMounted
|
||||
const minimizeOnStart = ref(false); // Placeholder state for minimize setting
|
||||
|
||||
// --- Lifecycle Hooks ---
|
||||
|
||||
onMounted(async () => {
|
||||
isLoading.value = true;
|
||||
errorMsg.value = null;
|
||||
try {
|
||||
isStartOnBoot.value = await isEnabled();
|
||||
// TODO: Fetch initial state for minimizeOnStart if applicable
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch autostart status:", err);
|
||||
errorMsg.value = "无法获取开机启动状态,请稍后重试。";
|
||||
// Keep default isStartOnBoot value (false)
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Watchers for Side Effects ---
|
||||
|
||||
watch(isStartOnBoot, async (newValue, oldValue) => {
|
||||
// Prevent running on initial load if oldValue is the initial default
|
||||
// or during the initial fetch if needed (though onMounted handles initial state)
|
||||
if (isLoading.value || newValue === oldValue) return; // Avoid unnecessary calls
|
||||
|
||||
errorMsg.value = null; // Clear previous errors
|
||||
try {
|
||||
if (newValue) {
|
||||
await enable();
|
||||
window.$message.success('已启用开机启动');
|
||||
} else {
|
||||
await disable();
|
||||
window.$message.success('已禁用开机启动'); // Provide feedback for disabling too
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to update autostart status:", err);
|
||||
errorMsg.value = `设置开机启动失败: ${err instanceof Error ? err.message : '未知错误'}`;
|
||||
// Revert UI state on failure
|
||||
isStartOnBoot.value = oldValue;
|
||||
window.$message.error('设置开机启动失败');
|
||||
}
|
||||
});
|
||||
const renderNotifidactionEnable = (name: NotificationType) => h(NCheckbox, {
|
||||
checked: setting.settings.notificationSettings?.enableTypes.includes(name),
|
||||
onUpdateChecked: (value) => {
|
||||
setting.settings.notificationSettings.enableTypes ??= [];
|
||||
if (value) {
|
||||
setting.settings.notificationSettings.enableTypes.push(name);
|
||||
} else {
|
||||
setting.settings.notificationSettings.enableTypes = setting.settings.notificationSettings.enableTypes.filter(type => type !== name);
|
||||
}
|
||||
},
|
||||
}, () => '启用');
|
||||
|
||||
watch(minimizeOnStart, (newValue) => {
|
||||
// TODO: Implement logic to save/apply minimizeOnStart setting
|
||||
// Example: saveToConfig('minimizeOnStart', newValue);
|
||||
console.log("Minimize on start:", newValue);
|
||||
if (newValue) {
|
||||
window.$message.info('启动后最小化功能待实现'); // Placeholder feedback
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpace
|
||||
vertical
|
||||
size="large"
|
||||
>
|
||||
<!-- Increased spacing -->
|
||||
<!-- 标题区域 -->
|
||||
<div style="max-width: 72rem; margin: 0 auto; padding: 0 1rem;">
|
||||
<!-- Added padding -->
|
||||
<h1 style="font-size: 1.875rem; font-weight: 600; margin-bottom: 1rem;">
|
||||
<!-- Added margin -->
|
||||
设置
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- 布局区域 -->
|
||||
<NGrid
|
||||
cols="24"
|
||||
item-responsive
|
||||
responsive="screen"
|
||||
>
|
||||
<!-- Left Navigation -->
|
||||
<NGridItem span="6">
|
||||
<!-- Responsive spans -->
|
||||
<NMenu
|
||||
v-model:value="currentTab"
|
||||
:options="navOptions"
|
||||
:indent="18"
|
||||
/>
|
||||
</NGridItem>
|
||||
|
||||
<!-- Right Content Area -->
|
||||
<NGridItem span="18">
|
||||
<!-- Responsive spans -->
|
||||
<NSpin :show="isLoading">
|
||||
<NSpace
|
||||
vertical
|
||||
size="large"
|
||||
>
|
||||
<!-- Global Error Display -->
|
||||
<NAlert
|
||||
v-if="errorMsg"
|
||||
title="操作错误"
|
||||
type="error"
|
||||
closable
|
||||
@close="errorMsg = null"
|
||||
>
|
||||
{{ errorMsg }}
|
||||
</NAlert>
|
||||
|
||||
<!-- Content Transition -->
|
||||
<Transition
|
||||
name="fade"
|
||||
mode="out-in"
|
||||
>
|
||||
<div :key="currentTab">
|
||||
<!-- Key needed for transition on content change -->
|
||||
<!-- General Settings -->
|
||||
<template v-if="currentTab === 'general'">
|
||||
<NSpace
|
||||
vertical
|
||||
size="large"
|
||||
>
|
||||
<NCard
|
||||
title="启动"
|
||||
:bordered="false"
|
||||
>
|
||||
<NSpace vertical>
|
||||
<NFormItem
|
||||
label="开机时启动应用"
|
||||
label-placement="left"
|
||||
>
|
||||
<NSwitch
|
||||
v-model:value="isStartOnBoot"
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem
|
||||
label="启动后最小化到托盘"
|
||||
label-placement="left"
|
||||
>
|
||||
<NSwitch v-model:value="minimizeOnStart" />
|
||||
<!-- Add appropriate logic/state for this -->
|
||||
</NFormItem>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
|
||||
<NCard
|
||||
title="外观"
|
||||
:bordered="false"
|
||||
>
|
||||
<NFormItem
|
||||
label="主题模式"
|
||||
label-placement="left"
|
||||
>
|
||||
<NRadioGroup
|
||||
v-model:value="themeType"
|
||||
name="theme-mode"
|
||||
:segmented="true"
|
||||
>
|
||||
<NRadio :value="ThemeType.Light">
|
||||
亮色
|
||||
</NRadio>
|
||||
<NRadio :value="ThemeType.Dark">
|
||||
暗色
|
||||
</NRadio>
|
||||
<NRadio :value="ThemeType.Auto">
|
||||
跟随系统
|
||||
</NRadio>
|
||||
</NRadioGroup>
|
||||
</NFormItem>
|
||||
</NCard>
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
<!-- Notification Settings -->
|
||||
<template v-else-if="currentTab === 'notification'">
|
||||
<NCard
|
||||
title="通知设置"
|
||||
:bordered="false"
|
||||
>
|
||||
<NSpace vertical>
|
||||
<NCheckbox
|
||||
v-model:checked="setting.settings.enableNotification"
|
||||
@update:checked="(value) => {
|
||||
setting.save()
|
||||
}"
|
||||
>
|
||||
启用通知
|
||||
</NCheckbox>
|
||||
|
||||
<template v-if="setting.settings.enableNotification">
|
||||
<NCard
|
||||
size="small"
|
||||
title="提问箱通知"
|
||||
>
|
||||
<template #header-extra>
|
||||
<component :is="renderNotifidactionEnable('question-box')" />
|
||||
</template>
|
||||
</NCard>
|
||||
<NCard
|
||||
size="small"
|
||||
title="弹幕相关"
|
||||
>
|
||||
<template #header-extra>
|
||||
<component :is="renderNotifidactionEnable('danmaku')" />
|
||||
</template>
|
||||
</NCard>
|
||||
</template>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
</template>
|
||||
|
||||
<!-- Other Settings -->
|
||||
<template v-else-if="currentTab === 'other'">
|
||||
<NCard
|
||||
title="其他设置"
|
||||
:bordered="false"
|
||||
>
|
||||
<p>其他设置将显示在这里。</p>
|
||||
</NCard>
|
||||
</template>
|
||||
|
||||
<!-- About Section -->
|
||||
<template v-else-if="currentTab === 'about'">
|
||||
<NCard
|
||||
title="关于"
|
||||
:bordered="false"
|
||||
>
|
||||
<template #header-extra>
|
||||
<div
|
||||
style="width: 10px; height: 10px;"
|
||||
@click="$router.push({name: 'client-test'})"
|
||||
/>
|
||||
</template>
|
||||
<p>应用名称: Your App Name</p>
|
||||
<p>版本: 1.0.0</p>
|
||||
<!-- Add more about info -->
|
||||
</NCard>
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
</NSpace>
|
||||
<template #description>
|
||||
正在加载设置...
|
||||
</template>
|
||||
</NSpin>
|
||||
</NGridItem>
|
||||
</NGrid>
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Scoped styles if needed, e.g., for the transition */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Optional: Adjust NFormItem label alignment if needed */
|
||||
/* :deep(.n-form-item-label) { */
|
||||
/* Add custom styles */
|
||||
/* } */
|
||||
</style>
|
||||
31
src/client/ClientTest.vue
Normal file
31
src/client/ClientTest.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { isPermissionGranted, onAction, sendNotification } from '@tauri-apps/plugin-notification';
|
||||
|
||||
async function testNotification() {
|
||||
let permissionGranted = await isPermissionGranted();
|
||||
if (permissionGranted) {
|
||||
sendNotification({
|
||||
title: "测试通知",
|
||||
body: "这是一个测试通知",
|
||||
silent: false,
|
||||
extra: { type: 'test' },
|
||||
});
|
||||
onAction((event) => {
|
||||
console.log('Notification clicked:', event);
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<NFlex>
|
||||
<NButton
|
||||
type="primary"
|
||||
@click="testNotification"
|
||||
>
|
||||
测试通知
|
||||
</NButton>
|
||||
</NFlex>
|
||||
</div>
|
||||
</template>
|
||||
154
src/client/WindowBar.vue
Normal file
154
src/client/WindowBar.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { Maximize24Filled, SquareMultiple24Regular } from '@vicons/fluent'; // Maximize 和 Restore 图标
|
||||
import { Close, RemoveOutline as Minus } from '@vicons/ionicons5'; // Close 和 Minimize 图标
|
||||
import { NFlex, NButton } from 'naive-ui'; // 显式导入 Naive UI 组件(好习惯)
|
||||
import { UnlistenFn } from '@tauri-apps/api/event';
|
||||
|
||||
const appWindow = getCurrentWindow();
|
||||
const isMaximized = ref(false);
|
||||
let unlisten: UnlistenFn | null = null;
|
||||
|
||||
// --- Window State Handling ---
|
||||
|
||||
// 更新最大化状态的函数
|
||||
const updateMaximizedState = async () => {
|
||||
isMaximized.value = await appWindow.isMaximized();
|
||||
};
|
||||
|
||||
// --- Event Handlers ---
|
||||
|
||||
// 处理标题栏的鼠标按下事件 (拖动/双击最大化)
|
||||
const handleTitlebarMouseDown = (event: MouseEvent) => {
|
||||
// 确保是鼠标左键按下
|
||||
if (event.buttons === 1) {
|
||||
// event.detail 在 mousedown 事件中可以用来检测点击次数
|
||||
if (event.detail === 2) {
|
||||
// 双击:切换最大化
|
||||
appWindow.toggleMaximize();
|
||||
} else {
|
||||
// 单击:开始拖动
|
||||
appWindow.startDragging();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// --- Lifecycle Hooks ---
|
||||
|
||||
onMounted(async () => {
|
||||
// 1. 组件挂载时,获取并设置初始的最大化状态
|
||||
await updateMaximizedState();
|
||||
|
||||
// 2. 监听窗口大小变化事件,当窗口状态改变时(包括最大化/恢复)更新状态
|
||||
// Tauri v1 使用 'tauri://resize', Tauri v2 可能有更具体的事件,但 resize 通常会触发
|
||||
unlisten = await appWindow.onResized(() => {
|
||||
updateMaximizedState();
|
||||
});
|
||||
|
||||
// 注意:某些情况下 (如 Linux 上的某些窗口管理器),
|
||||
// toggleMaximize 可能不会立即触发 onResized。
|
||||
// 如果遇到图标不更新的问题,可以考虑在 toggleMaximize 调用后加一个小的延时再手动调用 updateMaximizedState。
|
||||
// 例如:
|
||||
// const handleToggleMaximize = () => {
|
||||
// appWindow.toggleMaximize();
|
||||
// setTimeout(updateMaximizedState, 100); // 略微延迟更新
|
||||
// }
|
||||
// 然后在 maximize 按钮上使用 @click="handleToggleMaximize"
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// 组件卸载时,移除事件监听器,防止内存泄漏
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
}
|
||||
});
|
||||
|
||||
// --- Window Control Functions ---
|
||||
const minimizeWindow = () => appWindow.minimize();
|
||||
const toggleMaximizeWindow = () => appWindow.toggleMaximize();
|
||||
const closeWindow = () => appWindow.hide();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NFlex
|
||||
class="titlebar"
|
||||
>
|
||||
<NFlex
|
||||
style="flex: 1; padding-left: 8px;"
|
||||
align="center"
|
||||
@mousedown="handleTitlebarMouseDown"
|
||||
>
|
||||
<NText>
|
||||
<span class="title">VTsuruEventFetcher</span>
|
||||
</NText>
|
||||
</NFlex>
|
||||
<NFlex
|
||||
data-tauri-drag-region="true"
|
||||
justify="flex-end"
|
||||
align="center"
|
||||
@dblclick="toggleMaximizeWindow"
|
||||
>
|
||||
<!-- 注意: data-tauri-drag-region 会使整个区域可拖动 -->
|
||||
<!-- 如果按钮区域不希望触发拖动(通常是这样),需要确保按钮本身不被拖动 -->
|
||||
<!-- Naive UI 的 NButton 通常会阻止事件冒泡,所以一般没问题 -->
|
||||
<!-- 如果使用普通 <button>,可能需要加 @mousedown.stop -->
|
||||
|
||||
<NButton
|
||||
quaternary
|
||||
circle
|
||||
size="tiny"
|
||||
aria-label="Minimize"
|
||||
@click="minimizeWindow"
|
||||
>
|
||||
<Minus class="icon" />
|
||||
</NButton>
|
||||
<NButton
|
||||
quaternary
|
||||
circle
|
||||
size="tiny"
|
||||
:aria-label="isMaximized ? 'Restore' : 'Maximize'"
|
||||
@click="toggleMaximizeWindow"
|
||||
>
|
||||
<!-- 根据 isMaximized 状态切换图标 -->
|
||||
<component
|
||||
:is="isMaximized ? SquareMultiple24Regular : Maximize24Filled"
|
||||
class="icon"
|
||||
style="width: 14px; height: 14px;"
|
||||
/>
|
||||
</NButton>
|
||||
<NButton
|
||||
quaternary
|
||||
circle
|
||||
size="tiny"
|
||||
aria-label="Close"
|
||||
@click="closeWindow"
|
||||
>
|
||||
<Close class="icon" />
|
||||
</NButton>
|
||||
</NFlex>
|
||||
</NFlex>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.titlebar {
|
||||
flex: 1;
|
||||
height: 30px;
|
||||
border-bottom: 1px solid var(--border-color); /* 使用 Naive UI 边框颜色变量或默认值 */
|
||||
user-select: none; /* 防止拖动时选中文本 */
|
||||
padding: 0 4px; /* 给按钮一些边距 */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 如果需要让按钮区域不可拖动(虽然 NButton 通常没问题),可以这样设置 */
|
||||
/* .titlebar > .n-button {
|
||||
-webkit-app-region: no-drag;
|
||||
app-region: no-drag;
|
||||
} */
|
||||
|
||||
.icon {
|
||||
width: 16px; /* 统一设置图标大小 */
|
||||
height: 16px;
|
||||
}
|
||||
</style>
|
||||
92
src/client/data/biliLogin.ts
Normal file
92
src/client/data/biliLogin.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { fetch } from '@tauri-apps/plugin-http';
|
||||
import { error } from '@tauri-apps/plugin-log'
|
||||
import { QueryBiliAPI } from './utils';
|
||||
|
||||
export async function checkLoginStatusAsync(): Promise<boolean> {
|
||||
const url = 'https://api.bilibili.com/x/web-interface/nav/stat';
|
||||
const response = await fetch(url);
|
||||
const json = await response.json();
|
||||
|
||||
return json.code === 0;
|
||||
}
|
||||
|
||||
export async function getUidAsync(): Promise<number> {
|
||||
const url = 'https://api.live.bilibili.com/xlive/web-ucenter/user/get_user_info';
|
||||
const response = await fetch(url);
|
||||
const json = await response.json();
|
||||
|
||||
if (json.data && json.data.uid) {
|
||||
return json.data.uid;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
// 二维码地址及扫码密钥
|
||||
export async function getLoginUrlAsync(): Promise<any> {
|
||||
const url = 'https://passport.bilibili.com/x/passport-login/web/qrcode/generate';
|
||||
const response = await QueryBiliAPI(url, 'GET')
|
||||
if (!response.ok) {
|
||||
const result = await response.text();
|
||||
error('无法获取B站登陆二维码: ' + result);
|
||||
throw new Error('获取二维码地址失败');
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export async function getLoginUrlDataAsync(): Promise<{
|
||||
url: string;
|
||||
qrcode_key: string;
|
||||
}> {
|
||||
const message = await getLoginUrlAsync();
|
||||
if (message.code !== 0) {
|
||||
throw new Error('获取二维码地址失败');
|
||||
}
|
||||
return message.data as {
|
||||
url: string;
|
||||
qrcode_key: string;
|
||||
};
|
||||
}
|
||||
type QRCodeLoginInfo =
|
||||
| { status: 'expired' }
|
||||
| { status: 'unknown' }
|
||||
| { status: 'scanned' }
|
||||
| { status: 'waiting' }
|
||||
| { status: 'confirmed'; cookie: string; refresh_token: string };
|
||||
export async function getLoginInfoAsync(qrcodeKey: string): Promise<QRCodeLoginInfo> {
|
||||
const url = `https://passport.bilibili.com/x/passport-login/web/qrcode/poll?qrcode_key=${qrcodeKey}&source=main-fe-header`;
|
||||
const response = await QueryBiliAPI(url);
|
||||
const message = await response.json();
|
||||
|
||||
if (!message.data) {
|
||||
throw new Error('获取登录信息失败');
|
||||
}
|
||||
|
||||
if (message.data.code !== 0) {
|
||||
switch (message.data.code) {
|
||||
case 86038:
|
||||
return { status: 'expired' };
|
||||
case 86090:
|
||||
return { status: 'scanned' };
|
||||
case 86101:
|
||||
return { status: 'waiting' };
|
||||
default:
|
||||
return { status: 'unknown' };
|
||||
}
|
||||
}
|
||||
|
||||
const cookies = response.headers.get('set-cookie');
|
||||
if (!cookies) {
|
||||
throw new Error('无法获取 Cookie');
|
||||
}
|
||||
|
||||
return { status: 'confirmed', cookie: extractCookie(cookies), refresh_token: message.data.refresh_token };
|
||||
}
|
||||
|
||||
function extractCookie(cookies: string): string {
|
||||
const cookieArray = cookies
|
||||
.split(',')
|
||||
.map((cookie) => cookie.split(';')[0].trim())
|
||||
.filter(Boolean);
|
||||
const cookieSet = new Set(cookieArray);
|
||||
return Array.from(cookieSet).join('; ');
|
||||
}
|
||||
236
src/client/data/info.ts
Normal file
236
src/client/data/info.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { ref } from 'vue';
|
||||
import { format } from 'date-fns';
|
||||
import { info, error } from '@tauri-apps/plugin-log';
|
||||
import { QueryBiliAPI } from './utils'; // 假设 Bili API 工具路径
|
||||
import { BiliRoomInfo, BiliStreamingInfo, FetcherStatisticData } from './models'; // 假设模型路径
|
||||
import { useTauriStore } from '../store/useTauriStore';
|
||||
// import { useAccount } from '@/api/account'; // 如果需要账户信息
|
||||
|
||||
// const accountInfo = useAccount(); // 如果需要
|
||||
|
||||
export const STATISTIC_STORE_KEY = 'webfetcher.statistics';
|
||||
|
||||
/**
|
||||
* 当前日期 (YYYY-MM-DD) 的统计数据 (会被持久化)
|
||||
*/
|
||||
export const currentStatistic = ref<FetcherStatisticData>();
|
||||
/**
|
||||
* 标记当前统计数据是否已更新且需要保存
|
||||
*/
|
||||
export const shouldUpdateStatistic = ref(false);
|
||||
|
||||
/**
|
||||
* 直播流信息 (从B站API获取)
|
||||
*/
|
||||
export const streamingInfo = ref<BiliStreamingInfo>({
|
||||
status: 'prepare', // 初始状态
|
||||
} as BiliStreamingInfo);
|
||||
|
||||
/**
|
||||
* 房间基本信息 (从B站API获取)
|
||||
*/
|
||||
export const roomInfo = ref<BiliRoomInfo>(); // 可以添加房间信息
|
||||
|
||||
// --- Bili API 更新相关 ---
|
||||
const updateCount = ref(0); // 用于控制API调用频率的计数器
|
||||
|
||||
/**
|
||||
* 初始化统计和信息获取逻辑
|
||||
*/
|
||||
export function initInfo() {
|
||||
// 立即执行一次以加载或初始化当天数据
|
||||
updateCallback();
|
||||
// 设置定时器,定期检查和保存统计数据,并更新直播间信息
|
||||
setInterval(() => {
|
||||
updateCallback();
|
||||
}, 5000); // 每 5 秒检查一次统计数据保存和更新直播信息
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时回调函数: 处理统计数据持久化和B站信息更新
|
||||
*/
|
||||
async function updateCallback() {
|
||||
const store = useTauriStore();
|
||||
const currentDate = format(new Date(), 'yyyy-MM-dd');
|
||||
const key = `${STATISTIC_STORE_KEY}.${currentDate}`;
|
||||
|
||||
// --- 统计数据管理 ---
|
||||
// 检查是否需要加载或初始化当天的统计数据
|
||||
if (!currentStatistic.value || currentStatistic.value.date !== currentDate) {
|
||||
const loadedData = await store.get<FetcherStatisticData>(key);
|
||||
if (loadedData && loadedData.date === currentDate) {
|
||||
currentStatistic.value = loadedData;
|
||||
// 确保 eventTypeCounts 存在
|
||||
if (!currentStatistic.value.eventTypeCounts) {
|
||||
currentStatistic.value.eventTypeCounts = {};
|
||||
}
|
||||
// info(`Loaded statistics for ${currentDate}`); // 日志保持不变
|
||||
} else {
|
||||
info(`Initializing statistics for new day: ${currentDate}`);
|
||||
currentStatistic.value = {
|
||||
date: currentDate,
|
||||
count: 0,
|
||||
eventTypeCounts: {}, // 初始化类型计数
|
||||
};
|
||||
await store.set(key, currentStatistic.value); // 立即保存新一天的初始结构
|
||||
shouldUpdateStatistic.value = false; // 重置保存标记
|
||||
|
||||
// 清理旧数据逻辑 (保持不变)
|
||||
const allKeys = (await store.store.keys()).filter((k) => k.startsWith(STATISTIC_STORE_KEY));
|
||||
if (allKeys.length > 30) { // 例如,只保留最近30天的数据
|
||||
allKeys.sort(); // 按日期字符串升序排序
|
||||
const oldestKey = allKeys[0];
|
||||
await store.store.delete(oldestKey);
|
||||
info('清理过期统计数据: ' + oldestKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果数据有更新,则保存
|
||||
if (shouldUpdateStatistic.value && currentStatistic.value) {
|
||||
try {
|
||||
await store.set(key, currentStatistic.value);
|
||||
shouldUpdateStatistic.value = false; // 保存后重置标记
|
||||
} catch (err) {
|
||||
error("Failed to save statistics: " + err);
|
||||
}
|
||||
}
|
||||
|
||||
// --- B站信息更新 ---
|
||||
let updateDelay = 30; // 默认30秒更新一次房间信息
|
||||
if (streamingInfo.value.status === 'streaming' && !import.meta.env.DEV) {
|
||||
updateDelay = 15; // 直播中15秒更新一次 (可以适当调整)
|
||||
}
|
||||
// 使用取模运算控制调用频率
|
||||
if (updateCount.value % (updateDelay / 5) === 0) { // 因为主循环是5秒一次
|
||||
updateRoomAndStreamingInfo();
|
||||
}
|
||||
updateCount.value++;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一个接收到的事件 (由 useWebFetcher 调用)
|
||||
* @param eventType 事件类型字符串 (例如 "DANMU_MSG")
|
||||
*/
|
||||
export function recordEvent(eventType: string) {
|
||||
const currentDate = format(new Date(), 'yyyy-MM-dd');
|
||||
|
||||
// 确保 currentStatistic 已为当天初始化
|
||||
if (!currentStatistic.value || currentStatistic.value.date !== currentDate) {
|
||||
// 理论上 updateCallback 会先执行初始化,这里加个警告以防万一
|
||||
console.warn("recordEvent called before currentStatistic was initialized for today.");
|
||||
// 可以选择在这里强制调用一次 updateCallback 来初始化,但这可能是异步的
|
||||
// await updateCallback(); // 可能会引入复杂性
|
||||
return; // 或者直接返回,丢失这个事件计数
|
||||
}
|
||||
|
||||
// 增加总数
|
||||
currentStatistic.value.count++;
|
||||
|
||||
// 增加对应类型的计数
|
||||
if (!currentStatistic.value.eventTypeCounts) {
|
||||
currentStatistic.value.eventTypeCounts = {}; // 防御性初始化
|
||||
}
|
||||
currentStatistic.value.eventTypeCounts[eventType] = (currentStatistic.value.eventTypeCounts[eventType] || 0) + 1;
|
||||
|
||||
// 标记需要保存
|
||||
shouldUpdateStatistic.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 command 数据中解析事件类型
|
||||
* (需要根据实际接收到的数据结构调整)
|
||||
*/
|
||||
export function getEventType(command: any): string {
|
||||
if (typeof command === 'string') {
|
||||
try {
|
||||
command = JSON.parse(command);
|
||||
} catch (e) {
|
||||
return 'UNKNOWN_FORMAT';
|
||||
}
|
||||
}
|
||||
|
||||
if (command && typeof command === 'object') {
|
||||
// 优先使用 'cmd' 字段 (常见于 Web 或 OpenLive)
|
||||
if (command.cmd) return command.cmd;
|
||||
// 备选 'command' 字段
|
||||
if (command.command) return command.command;
|
||||
// 备选 'type' 字段
|
||||
if (command.type) return command.type;
|
||||
}
|
||||
return 'UNKNOWN'; // 未知类型
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定天数的历史统计数据
|
||||
* @param days 要获取的天数,默认为 7
|
||||
*/
|
||||
export async function getHistoricalStatistics(days: number = 7): Promise<FetcherStatisticData[]> {
|
||||
const store = useTauriStore();
|
||||
const keys = (await store.store.keys())
|
||||
.filter(key => key.startsWith(STATISTIC_STORE_KEY))
|
||||
.sort((a, b) => b.localeCompare(a)); // 按日期降序排序
|
||||
|
||||
const historicalData: FetcherStatisticData[] = [];
|
||||
for (let i = 0; i < Math.min(days, keys.length); i++) {
|
||||
const data = await store.get<FetcherStatisticData>(keys[i]);
|
||||
if (data) {
|
||||
historicalData.push(data);
|
||||
}
|
||||
}
|
||||
return historicalData.reverse(); // 返回按日期升序排列的结果
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新房间和直播流信息
|
||||
*/
|
||||
async function updateRoomAndStreamingInfo() {
|
||||
// 需要一个房间ID来查询,这个ID可能来自设置、登录信息或固定配置
|
||||
// const roomId = accountInfo.value?.roomid ?? settings.value.roomId; // 示例:获取房间ID
|
||||
const roomId = 21484828; // !!! 示例:这里需要替换成实际获取房间ID的逻辑 !!!
|
||||
if (!roomId) {
|
||||
// error("无法获取房间ID以更新直播信息");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 查询房间基本信息
|
||||
const roomRes = await QueryBiliAPI(
|
||||
`https://api.live.bilibili.com/room/v1/Room/get_info?room_id=${roomId}`
|
||||
);
|
||||
const json = await roomRes.json();
|
||||
if (json.code === 0) {
|
||||
roomInfo.value = json.data;
|
||||
} else {
|
||||
error(`Failed to fetch Bili room info: ${json.message}`);
|
||||
}
|
||||
// 查询直播流信息 (开放平台或Web接口)
|
||||
// 注意:这里可能需要根据所选模式(openlive/direct)调用不同的API
|
||||
// 以下是Web接口示例
|
||||
const streamRes = await QueryBiliAPI(
|
||||
`https://api.live.bilibili.com/room/v1/Room/get_status_info_by_uids?uids[]=${roomInfo.value?.uid}` // 通过 UID 查询
|
||||
// 或者使用 `https://api.live.bilibili.com/xlive/web-room/v1/index/getRoomBaseInfo?room_ids=${roomId}&req_biz=web_room_componet`
|
||||
);
|
||||
const streamJson = await streamRes.json();
|
||||
if (streamJson.code === 0 && streamJson.data && roomInfo.value?.uid) {
|
||||
// Web API 返回的是一个以 UID 为 key 的对象
|
||||
const uidData = streamJson.data[roomInfo.value.uid.toString()];
|
||||
if (uidData) {
|
||||
streamingInfo.value = {
|
||||
...uidData, // 合并获取到的数据
|
||||
status: uidData.live_status === 1 ? 'streaming' : uidData.live_status === 2 ? 'rotating' : 'prepare',
|
||||
};
|
||||
} else {
|
||||
// 如果没有对应UID的数据,可能表示未开播或接口变更
|
||||
//streamingInfo.value = { status: 'prepare', ...streamingInfo.value }; // 保留旧数据,状态设为prepare
|
||||
}
|
||||
} else if (streamJson.code !== 0) {
|
||||
error(`Failed to fetch Bili streaming info: ${streamJson.message}`);
|
||||
// 可选:如果获取失败,将状态设为未知或准备
|
||||
// streamingInfo.value = { status: 'prepare', ...streamingInfo.value };
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
error("Error updating room/streaming info: " + err);
|
||||
}
|
||||
}
|
||||
243
src/client/data/initialize.ts
Normal file
243
src/client/data/initialize.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { isLoggedIn, useAccount } from "@/api/account";
|
||||
import { attachConsole, info, warn } from "@tauri-apps/plugin-log";
|
||||
import { useSettings } from "../store/useSettings";
|
||||
import { useWebFetcher } from "@/store/useWebFetcher";
|
||||
import { useBiliCookie } from "../store/useBiliCookie";
|
||||
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 {
|
||||
isPermissionGranted,
|
||||
onAction,
|
||||
requestPermission,
|
||||
sendNotification,
|
||||
} from '@tauri-apps/plugin-notification';
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { CN_HOST } from "@/data/constants";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { check } from '@tauri-apps/plugin-updater';
|
||||
import { relaunch } from '@tauri-apps/plugin-process';
|
||||
|
||||
const accountInfo = useAccount();
|
||||
|
||||
export const clientInited = ref(false);
|
||||
let tray: TrayIcon;
|
||||
export async function initAll() {
|
||||
if (clientInited.value) {
|
||||
return;
|
||||
}
|
||||
let permissionGranted = await isPermissionGranted();
|
||||
|
||||
// If not we need to request it
|
||||
if (!permissionGranted) {
|
||||
const permission = await requestPermission();
|
||||
permissionGranted = permission === 'granted';
|
||||
if (permissionGranted) {
|
||||
info('Notification permission granted');
|
||||
}
|
||||
|
||||
}
|
||||
initNotificationHandler();
|
||||
const detach = await attachConsole();
|
||||
const settings = useSettings();
|
||||
const biliCookie = useBiliCookie();
|
||||
await settings.init();
|
||||
info('[init] 已加载账户信息');
|
||||
biliCookie.init();
|
||||
info('[init] 已加载bilibili cookie');
|
||||
initInfo();
|
||||
info('[init] 开始更新数据');
|
||||
|
||||
if (isLoggedIn && accountInfo.value.isBiliVerified) {
|
||||
const danmakuInitNoticeRef = window.$notification.info({
|
||||
title: '正在初始化弹幕客户端...',
|
||||
closable: false
|
||||
});
|
||||
const result = await initDanmakuClient();
|
||||
danmakuInitNoticeRef.destroy();
|
||||
if (result.success) {
|
||||
window.$notification.success({
|
||||
title: '弹幕客户端初始化完成',
|
||||
duration: 3000
|
||||
});
|
||||
} else {
|
||||
window.$notification.error({
|
||||
title: '弹幕客户端初始化失败: ' + result.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
info('[init] 已加载弹幕客户端');
|
||||
// 初始化系统托盘图标和菜单
|
||||
const menu = await Menu.new({
|
||||
items: [
|
||||
{
|
||||
id: 'quit',
|
||||
text: '退出',
|
||||
action: () => {
|
||||
invoke('quit_app');
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const iconData = await (await fetch('https://oss.suki.club/vtsuru/icon.ico')).arrayBuffer();
|
||||
const appWindow = getCurrentWindow();
|
||||
const options: TrayIconOptions = {
|
||||
// here you can add a tray menu, title, tooltip, event handler, etc
|
||||
menu: menu,
|
||||
title: 'VTsuru.Client',
|
||||
tooltip: 'VTsuru 事件收集器',
|
||||
icon: iconData,
|
||||
action: (event) => {
|
||||
|
||||
switch (event.type) {
|
||||
case 'DoubleClick':
|
||||
appWindow.show();
|
||||
break;
|
||||
case 'Click':
|
||||
appWindow.show();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
tray = await TrayIcon.new(options);
|
||||
|
||||
clientInited.value = true;
|
||||
}
|
||||
export function OnClientUnmounted() {
|
||||
if (clientInited.value) {
|
||||
clientInited.value = false;
|
||||
}
|
||||
|
||||
tray.close();
|
||||
}
|
||||
|
||||
async function checkUpdate() {
|
||||
const update = await check();
|
||||
if (update) {
|
||||
console.log(
|
||||
`found update ${update.version} from ${update.date} with notes ${update.body}`
|
||||
);
|
||||
let downloaded = 0;
|
||||
let contentLength = 0;
|
||||
// alternatively we could also call update.download() and update.install() separately
|
||||
await update.downloadAndInstall((event) => {
|
||||
switch (event.event) {
|
||||
case 'Started':
|
||||
contentLength = event.data.contentLength || 0;
|
||||
console.log(`started downloading ${event.data.contentLength} bytes`);
|
||||
break;
|
||||
case 'Progress':
|
||||
downloaded += event.data.chunkLength;
|
||||
console.log(`downloaded ${downloaded} from ${contentLength}`);
|
||||
break;
|
||||
case 'Finished':
|
||||
console.log('download finished');
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
console.log('update installed');
|
||||
await relaunch();
|
||||
}
|
||||
}
|
||||
|
||||
export const isInitedDanmakuClient = ref(false);
|
||||
export const isInitingDanmakuClient = ref(false);
|
||||
export async function initDanmakuClient() {
|
||||
const biliCookie = useBiliCookie();
|
||||
const settings = useSettings();
|
||||
if (isInitedDanmakuClient.value || isInitingDanmakuClient.value) {
|
||||
return { success: true, message: '' };
|
||||
}
|
||||
isInitingDanmakuClient.value = true;
|
||||
let result = { success: false, message: '' };
|
||||
try {
|
||||
if (isLoggedIn) {
|
||||
if (settings.settings.useDanmakuClientType === 'openlive') {
|
||||
result = await initOpenLive();
|
||||
} else {
|
||||
const cookie = await biliCookie.getBiliCookie();
|
||||
if (!cookie) {
|
||||
if (settings.settings.fallbackToOpenLive) {
|
||||
settings.settings.useDanmakuClientType = 'openlive';
|
||||
settings.save();
|
||||
info('未设置bilibili cookie, 根据设置切换为openlive');
|
||||
result = await initOpenLive();
|
||||
} else {
|
||||
info('未设置bilibili cookie, 跳过弹幕客户端初始化');
|
||||
window.$notification.warning({
|
||||
title: '未设置bilibili cookie, 跳过弹幕客户端初始化',
|
||||
duration: 5,
|
||||
});
|
||||
result = { success: false, message: '未设置bilibili cookie' };
|
||||
}
|
||||
} else {
|
||||
const resp = await callStartDanmakuClient();
|
||||
if (!resp?.success) {
|
||||
warn('加载弹幕客户端失败: ' + resp?.message);
|
||||
result = { success: false, message: resp?.message };
|
||||
} else {
|
||||
info('已加载弹幕客户端');
|
||||
result = { success: true, message: '' };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
warn('加载弹幕客户端失败: ' + err);
|
||||
return { success: false, message: '加载弹幕客户端失败' };
|
||||
} finally {
|
||||
if (result) {
|
||||
isInitedDanmakuClient.value = true;
|
||||
}
|
||||
isInitingDanmakuClient.value = false;
|
||||
}
|
||||
}
|
||||
export async function initOpenLive() {
|
||||
const reuslt = await callStartDanmakuClient();
|
||||
if (reuslt?.success == true) {
|
||||
info('已加载弹幕客户端 [openlive]');
|
||||
} else {
|
||||
warn('加载弹幕客户端失败 [openlive]: ' + reuslt?.message);
|
||||
}
|
||||
return reuslt;
|
||||
}
|
||||
function initNotificationHandler(){
|
||||
onAction((event) => {
|
||||
if (event.extra?.type === 'question-box') {
|
||||
openUrl(CN_HOST + '/manage/question-box');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function callStartDanmakuClient() {
|
||||
const biliCookie = useBiliCookie();
|
||||
const settings = useSettings();
|
||||
const webFetcher = useWebFetcher();
|
||||
if (settings.settings.useDanmakuClientType === 'direct') {
|
||||
const key = await getRoomKey(
|
||||
accountInfo.value.biliRoomId!, await biliCookie.getBiliCookie() || '');
|
||||
if (!key) {
|
||||
warn('获取房间密钥失败, 无法连接弹幕客户端');
|
||||
return { success: false, message: '无法获取房间密钥' };
|
||||
}
|
||||
const buvid = await getBuvid();
|
||||
if (!buvid) {
|
||||
warn('获取buvid失败, 无法连接弹幕客户端');
|
||||
return { success: false, message: '无法获取buvid' };
|
||||
}
|
||||
return await webFetcher.Start('direct', {
|
||||
roomId: accountInfo.value.biliRoomId!,
|
||||
buvid: buvid.data,
|
||||
token: key,
|
||||
tokenUserId: biliCookie.uId!,
|
||||
}, true);
|
||||
} else {
|
||||
return await webFetcher.Start('openlive', undefined, true);
|
||||
}
|
||||
}
|
||||
282
src/client/data/models.ts
Normal file
282
src/client/data/models.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
export interface EventFetcherStateModel {
|
||||
online: boolean;
|
||||
status: { [errorCode: string]: string };
|
||||
version?: string;
|
||||
todayReceive: number;
|
||||
useCookie: boolean;
|
||||
type: EventFetcherType;
|
||||
}
|
||||
|
||||
export enum EventFetcherType {
|
||||
Application,
|
||||
OBS,
|
||||
Server,
|
||||
Tauri,
|
||||
}
|
||||
|
||||
export type BiliRoomInfo = {
|
||||
uid: number;
|
||||
room_id: number;
|
||||
short_id: number;
|
||||
attention: number;
|
||||
online: number;
|
||||
is_portrait: boolean;
|
||||
description: string;
|
||||
live_status: number;
|
||||
area_id: number;
|
||||
parent_area_id: number;
|
||||
parent_area_name: string;
|
||||
old_area_id: number;
|
||||
background: string;
|
||||
title: string;
|
||||
user_cover: string;
|
||||
keyframe: string;
|
||||
is_strict_room: boolean;
|
||||
live_time: string;
|
||||
tags: string;
|
||||
is_anchor: number;
|
||||
room_silent_type: string;
|
||||
room_silent_level: number;
|
||||
room_silent_second: number;
|
||||
area_name: string;
|
||||
pendants: string;
|
||||
area_pendants: string;
|
||||
hot_words: string[];
|
||||
hot_words_status: number;
|
||||
verify: string;
|
||||
new_pendants: {
|
||||
frame: {
|
||||
name: string;
|
||||
value: string;
|
||||
position: number;
|
||||
desc: string;
|
||||
area: number;
|
||||
area_old: number;
|
||||
bg_color: string;
|
||||
bg_pic: string;
|
||||
use_old_area: boolean;
|
||||
};
|
||||
badge: unknown; // null in the example, adjust to proper type if known
|
||||
mobile_frame: {
|
||||
name: string;
|
||||
value: string;
|
||||
position: number;
|
||||
desc: string;
|
||||
area: number;
|
||||
area_old: number;
|
||||
bg_color: string;
|
||||
bg_pic: string;
|
||||
use_old_area: boolean;
|
||||
};
|
||||
mobile_badge: unknown; // null in the example, adjust to proper type if known
|
||||
};
|
||||
up_session: string;
|
||||
pk_status: number;
|
||||
pk_id: number;
|
||||
battle_id: number;
|
||||
allow_change_area_time: number;
|
||||
allow_upload_cover_time: number;
|
||||
studio_info: {
|
||||
status: number;
|
||||
master_list: any[]; // empty array in the example, adjust to proper type if known
|
||||
};
|
||||
}
|
||||
|
||||
export type FetcherStatisticData = {
|
||||
date: string;
|
||||
count: number;
|
||||
eventTypeCounts: { [eventType: string]: number };
|
||||
};
|
||||
export type BiliStreamingInfo = {
|
||||
status: 'prepare' | 'streaming' | 'cycle';
|
||||
streamAt: Date;
|
||||
roomId: number;
|
||||
title: string;
|
||||
coverUrl: string;
|
||||
frameUrl: string;
|
||||
areaName: string;
|
||||
parentAreaName: string;
|
||||
online: number;
|
||||
attention: number;
|
||||
};
|
||||
|
||||
// Nested type for Vip Label
|
||||
interface VipLabel {
|
||||
path: string;
|
||||
text: string;
|
||||
label_theme: string;
|
||||
text_color: string;
|
||||
bg_style: number;
|
||||
bg_color: string;
|
||||
border_color: string;
|
||||
use_img_label: boolean;
|
||||
img_label_uri_hans: string;
|
||||
img_label_uri_hant: string;
|
||||
img_label_uri_hans_static: string;
|
||||
img_label_uri_hant_static: string;
|
||||
}
|
||||
|
||||
// Nested type for Avatar Icon
|
||||
interface AvatarIcon {
|
||||
icon_type: number;
|
||||
// Assuming icon_resource could contain arbitrary data or be empty
|
||||
icon_resource: Record<string, unknown> | {};
|
||||
}
|
||||
|
||||
// Nested type for Vip Info
|
||||
interface VipInfo {
|
||||
type: number;
|
||||
status: number;
|
||||
due_date: number; // Likely a Unix timestamp in milliseconds
|
||||
vip_pay_type: number;
|
||||
theme_type: number;
|
||||
label: VipLabel;
|
||||
avatar_subscript: number;
|
||||
nickname_color: string;
|
||||
role: number;
|
||||
avatar_subscript_url: string;
|
||||
tv_vip_status: number;
|
||||
tv_vip_pay_type: number;
|
||||
tv_due_date: number; // Likely a Unix timestamp in milliseconds or 0
|
||||
avatar_icon: AvatarIcon;
|
||||
}
|
||||
|
||||
// Nested type for Pendant Info
|
||||
interface PendantInfo {
|
||||
pid: number;
|
||||
name: string;
|
||||
image: string; // URL
|
||||
expire: number; // Likely a timestamp or duration
|
||||
image_enhance: string; // URL
|
||||
image_enhance_frame: string; // URL or empty string
|
||||
n_pid: number;
|
||||
}
|
||||
|
||||
// Nested type for Nameplate Info
|
||||
interface NameplateInfo {
|
||||
nid: number;
|
||||
name: string;
|
||||
image: string; // URL
|
||||
image_small: string; // URL
|
||||
level: string;
|
||||
condition: string;
|
||||
}
|
||||
|
||||
// Nested type for Official Info
|
||||
interface OfficialInfo {
|
||||
role: number;
|
||||
title: string;
|
||||
desc: string;
|
||||
type: number;
|
||||
}
|
||||
|
||||
// Nested type for Profession Info
|
||||
interface ProfessionInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
show_name: string;
|
||||
is_show: number; // Likely 0 or 1
|
||||
category_one: string;
|
||||
realname: string;
|
||||
title: string;
|
||||
department: string;
|
||||
certificate_no: string;
|
||||
certificate_show: boolean;
|
||||
}
|
||||
|
||||
// Nested type for Honours Colour
|
||||
interface HonoursColour {
|
||||
dark: string; // Hex color code
|
||||
normal: string; // Hex color code
|
||||
}
|
||||
|
||||
// Nested type for Honours Info
|
||||
interface HonoursInfo {
|
||||
mid: number;
|
||||
colour: HonoursColour;
|
||||
// Assuming tags could be an array of strings if not null
|
||||
tags: string[] | null;
|
||||
is_latest_100honour: number; // Likely 0 or 1
|
||||
}
|
||||
|
||||
// Nested type for Attestation Common Info
|
||||
interface CommonAttestationInfo {
|
||||
title: string;
|
||||
prefix: string;
|
||||
prefix_title: string;
|
||||
}
|
||||
|
||||
// Nested type for Attestation Splice Info
|
||||
interface SpliceAttestationInfo {
|
||||
title: string;
|
||||
}
|
||||
|
||||
// Nested type for Attestation Info
|
||||
interface AttestationInfo {
|
||||
type: number;
|
||||
common_info: CommonAttestationInfo;
|
||||
splice_info: SpliceAttestationInfo;
|
||||
icon: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
// Nested type for Expert Info
|
||||
interface ExpertInfo {
|
||||
title: string;
|
||||
state: number;
|
||||
type: number;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
// Nested type for Level Exp Info
|
||||
interface LevelExpInfo {
|
||||
current_level: number;
|
||||
current_min: number;
|
||||
current_exp: number;
|
||||
next_exp: number; // -1 might indicate max level or data not applicable
|
||||
level_up: number; // Likely a Unix timestamp
|
||||
}
|
||||
|
||||
// Main User Profile Type
|
||||
export type BiliUserProfile = {
|
||||
mid: number;
|
||||
name: string;
|
||||
sex: string; // Could be more specific like '男' | '女' | '保密' if desired
|
||||
face: string; // URL
|
||||
sign: string;
|
||||
rank: number;
|
||||
level: number;
|
||||
jointime: number; // Likely a Unix timestamp or 0
|
||||
moral: number;
|
||||
silence: number; // Likely 0 or 1
|
||||
email_status: number; // Likely 0 or 1
|
||||
tel_status: number; // Likely 0 or 1
|
||||
identification: number; // Likely 0 or 1
|
||||
vip: VipInfo;
|
||||
pendant: PendantInfo;
|
||||
nameplate: NameplateInfo;
|
||||
official: OfficialInfo;
|
||||
birthday: number; // Likely a Unix timestamp
|
||||
is_tourist: number; // Likely 0 or 1
|
||||
is_fake_account: number; // Likely 0 or 1
|
||||
pin_prompting: number; // Likely 0 or 1
|
||||
is_deleted: number; // Likely 0 or 1
|
||||
in_reg_audit: number; // Likely 0 or 1
|
||||
is_rip_user: boolean;
|
||||
profession: ProfessionInfo;
|
||||
face_nft: number;
|
||||
face_nft_new: number;
|
||||
is_senior_member: number; // Likely 0 or 1
|
||||
honours: HonoursInfo;
|
||||
digital_id: string;
|
||||
digital_type: number;
|
||||
attestation: AttestationInfo;
|
||||
expert_info: ExpertInfo;
|
||||
// Assuming name_render could be various types or null
|
||||
name_render: any | null;
|
||||
country_code: string;
|
||||
level_exp: LevelExpInfo;
|
||||
coins: number; // Can be float
|
||||
following: number;
|
||||
follower: number;
|
||||
};
|
||||
27
src/client/data/notification.ts
Normal file
27
src/client/data/notification.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { QAInfo } from "@/api/api-models";
|
||||
import { useSettings } from "../store/useSettings";
|
||||
import { isPermissionGranted, onAction, sendNotification } from "@tauri-apps/plugin-notification";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { CN_HOST } from "@/data/constants";
|
||||
|
||||
export async function onReceivedQuestion(question: QAInfo) {
|
||||
const setting = useSettings();
|
||||
if (setting.settings.notificationSettings.enableTypes.includes("question-box")) {
|
||||
window.$notification.info({
|
||||
title: "收到提问",
|
||||
description: '收到来自 [' + question.sender.name || '匿名用户' + '] 的提问',
|
||||
duration: 5,
|
||||
});
|
||||
let permissionGranted = await isPermissionGranted();
|
||||
if (permissionGranted) {
|
||||
sendNotification({
|
||||
title: "收到提问",
|
||||
body: '来自 [' + question.sender.name || '匿名用户' + '] 的提问',
|
||||
silent: false,
|
||||
extra: { type: 'question-box' },
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
77
src/client/data/utils.ts
Normal file
77
src/client/data/utils.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { fetch } from '@tauri-apps/plugin-http';
|
||||
import { useBiliCookie } from '../store/useBiliCookie';
|
||||
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 = '') {
|
||||
const u = new URL(url);
|
||||
return fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'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',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getRoomKey(roomId: number, cookie: string) {
|
||||
try {
|
||||
const result = await QueryBiliAPI(
|
||||
'https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo?id=' + roomId
|
||||
);
|
||||
const json = await result.json();
|
||||
if (json.code === 0) return json.data.token;
|
||||
else {
|
||||
error(`无法获取直播间key: ${json.message}`);
|
||||
}
|
||||
} catch (err) {
|
||||
error(`无法获取直播间key: ${err}`);
|
||||
}
|
||||
}
|
||||
export async function getBuvid() {
|
||||
try {
|
||||
const result = await QueryBiliAPI('https://api.bilibili.com/x/web-frontend/getbuvid');
|
||||
if (result.ok) {
|
||||
const json = await result.json();
|
||||
if (json.code === 0) return json.data.buvid;
|
||||
else {
|
||||
error(`无法获取buvid: ${json.message}`);
|
||||
}
|
||||
} else {
|
||||
error(`无法获取buvid: ${result.statusText}`);
|
||||
}
|
||||
} catch (err) {
|
||||
error(`无法获取buvid: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAuthInfo(): Promise<{
|
||||
data: any;
|
||||
message: string;
|
||||
}> {
|
||||
try {
|
||||
const data = await QueryPostAPI<any>(OPEN_LIVE_API_URL + 'start');
|
||||
if (data.code == 200) {
|
||||
console.log(`[open-live] 已获取认证信息`);
|
||||
return {
|
||||
data: data.data,
|
||||
message: '',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
data: null,
|
||||
message: data.message,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
data: null,
|
||||
message: err?.toString() || '未知错误',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
581
src/client/store/useBiliCookie.ts
Normal file
581
src/client/store/useBiliCookie.ts
Normal file
@@ -0,0 +1,581 @@
|
||||
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
|
||||
import { useTauriStore } from './useTauriStore';
|
||||
import { error, info, warn, debug } from '@tauri-apps/plugin-log';
|
||||
import { AES, enc, MD5 } from 'crypto-js';
|
||||
import { QueryBiliAPI } from '../data/utils';
|
||||
import { BiliUserProfile } from '../data/models';
|
||||
import { defineStore, acceptHMRUpdate } from 'pinia';
|
||||
import { ref, computed, shallowRef } from 'vue';
|
||||
|
||||
// --- 常量定义 ---
|
||||
// Tauri Store 存储键名
|
||||
export const BILI_COOKIE_KEY = 'user.bilibili.cookie';
|
||||
export const COOKIE_CLOUD_KEY = 'user.bilibili.cookie_cloud';
|
||||
export const USER_INFO_CACHE_KEY = 'cache.bilibili.userInfo';
|
||||
|
||||
// 检查周期 (毫秒)
|
||||
const REGULAR_CHECK_INTERVAL = 60 * 1000; // 每分钟检查一次 Cookie 有效性
|
||||
const CLOUD_SYNC_INTERVAL_CHECKS = 30; // 每 30 次常规检查后 (约 30 分钟) 同步一次 CookieCloud
|
||||
|
||||
// 用户信息缓存有效期 (毫秒)
|
||||
const USER_INFO_CACHE_DURATION = 5 * 60 * 1000; // 缓存 5 分钟
|
||||
|
||||
// --- 类型定义 ---
|
||||
|
||||
// Bilibili Cookie 存储数据结构
|
||||
type BiliCookieStoreData = {
|
||||
cookie: string;
|
||||
refreshToken?: string; // refreshToken 似乎未使用,设为可选
|
||||
lastRefresh?: Date; // 上次刷新时间,似乎未使用,设为可选
|
||||
};
|
||||
|
||||
// Cookie Cloud 配置数据结构
|
||||
export type CookieCloudConfig = {
|
||||
key: string;
|
||||
password: string;
|
||||
host?: string; // CookieCloud 服务地址,可选,有默认值
|
||||
};
|
||||
|
||||
// CookieCloud 导出的 Cookie 单项结构
|
||||
export interface CookieCloudCookie {
|
||||
domain: string;
|
||||
expirationDate: number;
|
||||
hostOnly: boolean;
|
||||
httpOnly: boolean;
|
||||
name: string;
|
||||
path: string;
|
||||
sameSite: string;
|
||||
secure: boolean;
|
||||
session: boolean;
|
||||
storeId: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
// CookieCloud 导出的完整数据结构
|
||||
interface CookieCloudExportData {
|
||||
cookie_data: Record<string, CookieCloudCookie[]>; // 按域名分组的 Cookie 数组
|
||||
local_storage_data?: Record<string, any>; // 本地存储数据 (可选)
|
||||
update_time: string; // 更新时间 ISO 8601 字符串
|
||||
}
|
||||
|
||||
// 用户信息缓存结构
|
||||
type UserInfoCache = {
|
||||
userInfo: BiliUserProfile;
|
||||
accessedAt: number; // 使用时间戳 (Date.now()) 以方便比较
|
||||
};
|
||||
|
||||
// CookieCloud 状态类型
|
||||
type CookieCloudState = 'unset' | 'valid' | 'invalid' | 'syncing';
|
||||
|
||||
// --- Store 定义 ---
|
||||
|
||||
export const useBiliCookie = defineStore('biliCookie', () => {
|
||||
// --- 依赖和持久化存储实例 ---
|
||||
// 使用 useTauriStore 获取持久化存储目标
|
||||
const biliCookieStore = useTauriStore().getTarget<BiliCookieStoreData>(BILI_COOKIE_KEY);
|
||||
const cookieCloudStore = useTauriStore().getTarget<CookieCloudConfig>(COOKIE_CLOUD_KEY);
|
||||
const userInfoCacheStore = useTauriStore().getTarget<UserInfoCache>(USER_INFO_CACHE_KEY);
|
||||
|
||||
// --- 核心状态 ---
|
||||
// 使用 shallowRef 存储用户信息对象,避免不必要的深度侦听,提高性能
|
||||
const _cachedUserInfo = shallowRef<UserInfoCache | undefined>();
|
||||
// 是否已从存储加载了 Cookie (不代表有效)
|
||||
const hasBiliCookie = ref(false);
|
||||
// 当前 Cookie 是否通过 Bilibili API 验证有效
|
||||
const isCookieValid = ref(false);
|
||||
// CookieCloud 配置及同步状态
|
||||
const cookieCloudState = ref<CookieCloudState>('unset');
|
||||
// Bilibili 用户 ID
|
||||
const uId = ref<number | undefined>();
|
||||
|
||||
// --- 计算属性 ---
|
||||
// 公开的用户信息,只读
|
||||
const userInfo = computed(() => _cachedUserInfo.value?.userInfo);
|
||||
|
||||
// --- 内部状态和变量 ---
|
||||
let _isInitialized = false; // 初始化标志,防止重复执行
|
||||
let _checkIntervalId: ReturnType<typeof setInterval> | null = null; // 定时检查器 ID
|
||||
let _checkCounter = 0; // 常规检查计数器,用于触发 CookieCloud 同步
|
||||
|
||||
// --- 私有辅助函数 ---
|
||||
|
||||
/**
|
||||
* @description 更新并持久化用户信息缓存
|
||||
* @param data Bilibili 用户信息
|
||||
*/
|
||||
const _updateUserInfoCache = async (data: BiliUserProfile): Promise<void> => {
|
||||
const cacheData: UserInfoCache = { userInfo: data, accessedAt: Date.now() };
|
||||
_cachedUserInfo.value = cacheData; // 更新内存缓存
|
||||
uId.value = data.mid; // 更新 uId
|
||||
try {
|
||||
await userInfoCacheStore.set(cacheData); // 持久化缓存
|
||||
debug('[BiliCookie] 用户信息缓存已更新并持久化');
|
||||
} catch (err) {
|
||||
error('[BiliCookie] 持久化用户信息缓存失败: ' + String(err));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description 清除用户信息缓存 (内存和持久化)
|
||||
*/
|
||||
const _clearUserInfoCache = async (): Promise<void> => {
|
||||
_cachedUserInfo.value = undefined; // 清除内存缓存
|
||||
uId.value = undefined; // 清除 uId
|
||||
try {
|
||||
await userInfoCacheStore.delete(); // 删除持久化缓存
|
||||
debug('[BiliCookie] 用户信息缓存已清除');
|
||||
} catch (err) {
|
||||
error('[BiliCookie] 清除持久化用户信息缓存失败: ' + String(err));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description 更新 Cookie 存在状态和有效状态
|
||||
* @param hasCookie Cookie 是否存在
|
||||
* @param isValid Cookie 是否有效
|
||||
*/
|
||||
const _updateCookieState = (hasCookie: boolean, isValid: boolean): void => {
|
||||
hasBiliCookie.value = hasCookie;
|
||||
isCookieValid.value = isValid;
|
||||
if (!hasCookie || !isValid) {
|
||||
// 如果 Cookie 不存在或无效,清除可能过时的用户信息缓存
|
||||
// 注意:这里采取了更严格的策略,无效则清除缓存,避免显示旧信息
|
||||
// _clearUserInfoCache(); // 考虑是否在无效时立即清除缓存
|
||||
debug(`[BiliCookie] Cookie 状态更新: hasCookie=${hasCookie}, isValid=${isValid}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description 检查提供的 Bilibili Cookie 是否有效
|
||||
* @param cookie 要验证的 Cookie 字符串
|
||||
* @returns Promise<{ valid: boolean; data?: BiliUserProfile }> 验证结果和用户信息 (如果有效)
|
||||
*/
|
||||
const _checkCookieValidity = async (cookie: string): Promise<{ valid: boolean; data?: BiliUserProfile; }> => {
|
||||
if (!cookie) {
|
||||
return { valid: false };
|
||||
}
|
||||
try {
|
||||
// 使用传入的 cookie 调用 Bilibili API
|
||||
const resp = await QueryBiliAPI('https://api.bilibili.com/x/space/myinfo', 'GET', cookie);
|
||||
|
||||
const json = await resp.json();
|
||||
if (json.code === 0 && json.data) {
|
||||
debug('[BiliCookie] Cookie 验证成功, 用户:', json.data.name);
|
||||
// 验证成功,更新用户信息缓存
|
||||
await _updateUserInfoCache(json.data);
|
||||
return { valid: true, data: json.data };
|
||||
} else {
|
||||
warn(`[BiliCookie] Cookie 验证失败 (API 返回): ${json.message || `code: ${json.code}`}`);
|
||||
return { valid: false };
|
||||
}
|
||||
} catch (err) {
|
||||
error('[BiliCookie] 验证 Cookie 时请求 Bilibili API 出错: ' + String(err));
|
||||
return { valid: false };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* @description 从 CookieCloud 服务获取并解密 Bilibili Cookie
|
||||
* @param config CookieCloud 配置 (如果提供,则使用此配置;否则使用已存储的配置)
|
||||
* @returns Promise<string> Bilibili Cookie 字符串
|
||||
* @throws 如果配置缺失、网络请求失败、解密失败或未找到 Bilibili Cookie,则抛出错误
|
||||
*/
|
||||
const _fetchAndDecryptFromCloud = async (config?: CookieCloudConfig): Promise<string> => {
|
||||
const cloudConfig = config ?? await cookieCloudStore.get(); // 获取配置
|
||||
|
||||
if (!cloudConfig?.key || !cloudConfig?.password) {
|
||||
throw new Error("CookieCloud 配置不完整 (缺少 Key 或 Password)");
|
||||
}
|
||||
|
||||
const host = cloudConfig.host || "https://cookie.vtsuru.live"; // 默认 Host
|
||||
const url = new URL(host);
|
||||
url.pathname = `/get/${cloudConfig.key}`;
|
||||
|
||||
info(`[BiliCookie] 正在从 CookieCloud (${url.hostname}) 获取 Cookie...`);
|
||||
|
||||
try {
|
||||
// 注意: 浏览器环境通常无法直接设置 User-Agent
|
||||
// 使用 Tauri fetch 发送请求
|
||||
const response = await tauriFetch(url.toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json' // 根据 CookieCloud API 要求可能需要调整
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`CookieCloud 请求失败: ${response.status} ${response.statusText}. ${errorText}`);
|
||||
}
|
||||
|
||||
const json = await response.json() as any; // 类型断言需要谨慎
|
||||
|
||||
if (json.encrypted) {
|
||||
// 执行解密
|
||||
try {
|
||||
const keyMaterial = MD5(cloudConfig.key + '-' + cloudConfig.password).toString();
|
||||
const decryptionKey = keyMaterial.substring(0, 16); // 取前16位作为 AES 密钥
|
||||
const decrypted = AES.decrypt(json.encrypted, decryptionKey).toString(enc.Utf8);
|
||||
|
||||
if (!decrypted) {
|
||||
throw new Error("解密结果为空,可能是密钥不匹配");
|
||||
}
|
||||
|
||||
const cookieData = JSON.parse(decrypted) as CookieCloudExportData;
|
||||
|
||||
// 提取 bilibili.com 的 Cookie
|
||||
const biliCookies = cookieData.cookie_data?.['bilibili.com'];
|
||||
if (!biliCookies || biliCookies.length === 0) {
|
||||
throw new Error("在 CookieCloud 数据中未找到 'bilibili.com' 的 Cookie");
|
||||
}
|
||||
|
||||
// 拼接 Cookie 字符串
|
||||
const cookieString = biliCookies
|
||||
.map(c => `${c.name}=${c.value}`)
|
||||
.join('; ');
|
||||
|
||||
info('[BiliCookie] CookieCloud Cookie 获取并解密成功');
|
||||
return cookieString;
|
||||
|
||||
} catch (decryptErr) {
|
||||
error('[BiliCookie] CookieCloud Cookie 解密失败: ' + String(decryptErr));
|
||||
throw new Error(`Cookie 解密失败: ${decryptErr instanceof Error ? decryptErr.message : String(decryptErr)}`);
|
||||
}
|
||||
} else if (json.cookie_data) {
|
||||
// 处理未加密的情况 (如果 CookieCloud 支持)
|
||||
warn('[BiliCookie] 从 CookieCloud 收到未加密的 Cookie 数据');
|
||||
const biliCookies = (json as CookieCloudExportData).cookie_data?.['bilibili.com'];
|
||||
if (!biliCookies || biliCookies.length === 0) {
|
||||
throw new Error("在 CookieCloud 数据中未找到 'bilibili.com' 的 Cookie");
|
||||
}
|
||||
const cookieString = biliCookies
|
||||
.map(c => `${c.name}=${c.value}`)
|
||||
.join('; ');
|
||||
return cookieString;
|
||||
}
|
||||
else {
|
||||
// API 返回了非预期的数据结构
|
||||
throw new Error(json.message || "从 CookieCloud 获取 Cookie 失败,响应格式不正确");
|
||||
}
|
||||
} catch (networkErr) {
|
||||
error('[BiliCookie] 请求 CookieCloud 时出错: ' + String(networkErr));
|
||||
throw new Error(`请求 CookieCloud 时出错: ${networkErr instanceof Error ? networkErr.message : String(networkErr)}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description 从已配置的 CookieCloud 同步 Cookie,并更新本地状态
|
||||
* @returns Promise<boolean> 是否同步并验证成功
|
||||
*/
|
||||
const _syncFromCookieCloud = async (): Promise<boolean> => {
|
||||
const config = await cookieCloudStore.get();
|
||||
if (!config?.key) {
|
||||
debug('[BiliCookie] 未配置 CookieCloud 或缺少 key,跳过同步');
|
||||
// 如果从未设置过,保持 unset;如果之前设置过但现在无效,标记为 invalid
|
||||
if (cookieCloudState.value !== 'unset') {
|
||||
cookieCloudState.value = 'invalid'; // 假设配置被清空意味着无效
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
cookieCloudState.value = 'syncing'; // 标记为同步中
|
||||
try {
|
||||
const cookieString = await _fetchAndDecryptFromCloud(config);
|
||||
// 验证从 Cloud 获取的 Cookie
|
||||
const validationResult = await _checkCookieValidity(cookieString);
|
||||
|
||||
if (validationResult.valid) {
|
||||
// 验证成功,保存 Cookie
|
||||
await setBiliCookie(cookieString); // setBiliCookie 内部会处理状态更新和持久化
|
||||
cookieCloudState.value = 'valid'; // 标记为有效
|
||||
info('[BiliCookie] 从 CookieCloud 同步并验证 Cookie 成功');
|
||||
return true;
|
||||
} else {
|
||||
// 从 Cloud 获取的 Cookie 无效
|
||||
warn('[BiliCookie] 从 CookieCloud 获取的 Cookie 无效');
|
||||
cookieCloudState.value = 'invalid'; // 标记为无效
|
||||
// 不更新本地 Cookie,保留当前有效的或无效的状态
|
||||
_updateCookieState(hasBiliCookie.value, false); // 显式标记当前cookie状态可能因云端无效而变为无效
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
error('[BiliCookie] CookieCloud 同步失败: ' + String(err));
|
||||
cookieCloudState.value = 'invalid'; // 同步出错,标记为无效
|
||||
// 同步失败不应影响当前的 isCookieValid 状态,除非需要强制失效
|
||||
// _updateCookieState(hasBiliCookie.value, false); // 可选:同步失败时强制本地cookie失效
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// --- 公开方法 ---
|
||||
|
||||
/**
|
||||
* @description 初始化 BiliCookie Store
|
||||
* - 加载持久化数据 (Cookie, Cloud 配置, 用户信息缓存)
|
||||
* - 检查 CookieCloud 配置状态
|
||||
* - 进行首次 Cookie 有效性检查 (或使用缓存)
|
||||
* - 启动定时检查任务
|
||||
*/
|
||||
const init = async (): Promise<void> => {
|
||||
if (_isInitialized) {
|
||||
debug('[BiliCookie] Store 已初始化,跳过');
|
||||
return;
|
||||
}
|
||||
_isInitialized = true;
|
||||
info('[BiliCookie] Store 初始化开始...');
|
||||
|
||||
// 1. 加载持久化数据
|
||||
const [storedCookieData, storedCloudConfig, storedUserInfo] = await Promise.all([
|
||||
biliCookieStore.get(),
|
||||
cookieCloudStore.get(),
|
||||
userInfoCacheStore.get(),
|
||||
]);
|
||||
|
||||
// 2. 处理 CookieCloud 配置
|
||||
if (storedCloudConfig?.key && storedCloudConfig?.password) {
|
||||
// 这里仅设置初始状态,有效性将在后续检查或同步中确认
|
||||
cookieCloudState.value = 'valid'; // 假设配置存在即可能有效,待验证
|
||||
info('[BiliCookie] 检测到已配置 CookieCloud');
|
||||
} else {
|
||||
cookieCloudState.value = 'unset';
|
||||
info('[BiliCookie] 未配置 CookieCloud');
|
||||
}
|
||||
|
||||
// 3. 处理用户信息缓存
|
||||
if (storedUserInfo && (Date.now() - storedUserInfo.accessedAt < USER_INFO_CACHE_DURATION)) {
|
||||
_cachedUserInfo.value = storedUserInfo;
|
||||
uId.value = storedUserInfo.userInfo.mid;
|
||||
info(`[BiliCookie] 从缓存加载有效用户信息: UID=${uId.value}`);
|
||||
// 如果缓存有效,可以初步认为 Cookie 是有效的 (至少在缓存有效期内是)
|
||||
_updateCookieState(!!storedCookieData?.cookie, true);
|
||||
} else {
|
||||
info('[BiliCookie] 无有效用户信息缓存');
|
||||
_updateCookieState(!!storedCookieData?.cookie, false); // 默认无效,待检查
|
||||
if (storedUserInfo) {
|
||||
// 如果有缓存但已过期,清除它
|
||||
await _clearUserInfoCache();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 4. 处理 Bilibili Cookie
|
||||
if (storedCookieData?.cookie) {
|
||||
hasBiliCookie.value = true; // 标记存在 Cookie
|
||||
info('[BiliCookie] 检测到已存储的 Bilibili Cookie');
|
||||
// 检查 Cookie 有效性,除非用户信息缓存有效且未过期
|
||||
if (!_cachedUserInfo.value) { // 只有在没有有效缓存时才立即检查
|
||||
debug('[BiliCookie] 无有效缓存,立即检查 Cookie 有效性...');
|
||||
const { valid } = await _checkCookieValidity(storedCookieData.cookie);
|
||||
_updateCookieState(true, valid); // 更新状态
|
||||
}
|
||||
} else {
|
||||
_updateCookieState(false, false); // 没有 Cookie,自然无效
|
||||
info('[BiliCookie] 未找到存储的 Bilibili Cookie');
|
||||
}
|
||||
|
||||
|
||||
// 5. 启动定时检查器
|
||||
if (_checkIntervalId) {
|
||||
clearInterval(_checkIntervalId); // 清除旧的定时器 (理论上不应存在)
|
||||
}
|
||||
_checkIntervalId = setInterval(check, REGULAR_CHECK_INTERVAL);
|
||||
info(`[BiliCookie] 定时检查已启动,周期: ${REGULAR_CHECK_INTERVAL / 1000} 秒`);
|
||||
|
||||
info('[BiliCookie] Store 初始化完成');
|
||||
};
|
||||
|
||||
/**
|
||||
* @description 定期检查 Cookie 有效性,并按需从 CookieCloud 同步
|
||||
* @param forceCheckCloud 是否强制立即尝试从 CookieCloud 同步 (通常由 init 调用)
|
||||
*/
|
||||
const check = async (forceCheckCloud: boolean = false): Promise<void> => {
|
||||
debug('[BiliCookie] 开始周期性检查...');
|
||||
_checkCounter++;
|
||||
|
||||
let cloudSyncAttempted = false;
|
||||
let cloudSyncSuccess = false;
|
||||
|
||||
// 检查是否需要从 CookieCloud 同步
|
||||
const shouldSyncCloud = forceCheckCloud || (_checkCounter % CLOUD_SYNC_INTERVAL_CHECKS === 0);
|
||||
|
||||
if (shouldSyncCloud && cookieCloudState.value !== 'unset' && cookieCloudState.value !== 'syncing') {
|
||||
info(`[BiliCookie] 触发 CookieCloud 同步 (计数: ${_checkCounter}, 强制: ${forceCheckCloud})`);
|
||||
cloudSyncAttempted = true;
|
||||
cloudSyncSuccess = await _syncFromCookieCloud();
|
||||
// 同步后重置计数器,避免连续同步
|
||||
_checkCounter = 0;
|
||||
}
|
||||
|
||||
// 如果没有尝试云同步,或者云同步失败,则检查本地 Cookie
|
||||
if (!cloudSyncAttempted || !cloudSyncSuccess) {
|
||||
debug('[BiliCookie] 检查本地存储的 Cookie 有效性...');
|
||||
const storedCookie = (await biliCookieStore.get())?.cookie;
|
||||
if (storedCookie) {
|
||||
const { valid } = await _checkCookieValidity(storedCookie);
|
||||
// 只有在云同步未成功时才更新状态,避免覆盖云同步设置的状态
|
||||
if (!cloudSyncSuccess) {
|
||||
_updateCookieState(true, valid);
|
||||
}
|
||||
} else {
|
||||
// 本地没有 Cookie
|
||||
_updateCookieState(false, false);
|
||||
// 如果本地没 cookie 但 cookieCloud 配置存在且非 syncing, 尝试一次同步
|
||||
if (!cloudSyncAttempted && cookieCloudState.value !== 'unset' && cookieCloudState.value !== 'syncing') {
|
||||
info('[BiliCookie] 本地无 Cookie,尝试从 CookieCloud 获取...');
|
||||
await _syncFromCookieCloud(); // 尝试获取一次
|
||||
_checkCounter = 0; // 同步后重置计数器
|
||||
}
|
||||
}
|
||||
}
|
||||
debug('[BiliCookie] 周期性检查结束');
|
||||
};
|
||||
|
||||
/**
|
||||
* @description 设置新的 Bilibili Cookie
|
||||
* @param cookie Cookie 字符串
|
||||
* @param refreshToken (可选) Bilibili refresh token
|
||||
*/
|
||||
const setBiliCookie = async (cookie: string, refreshToken?: string): Promise<void> => {
|
||||
info('[BiliCookie] 正在设置新的 Bilibili Cookie...');
|
||||
// 1. 验证新 Cookie 的有效性
|
||||
const { valid } = await _checkCookieValidity(cookie);
|
||||
|
||||
if (valid) {
|
||||
// 2. 如果有效,则持久化存储
|
||||
const dataToStore: BiliCookieStoreData = {
|
||||
cookie,
|
||||
...(refreshToken && { refreshToken }), // 仅在提供时添加 refreshToken
|
||||
lastRefresh: new Date() // 更新刷新时间戳
|
||||
};
|
||||
try {
|
||||
await biliCookieStore.set(dataToStore);
|
||||
info('[BiliCookie] 新 Bilibili Cookie 已验证并保存');
|
||||
_updateCookieState(true, true); // 更新状态为存在且有效
|
||||
} catch (err) {
|
||||
error('[BiliCookie] 保存 Bilibili Cookie 失败: ' + String(err));
|
||||
// 保存失败,状态回滚或标记为错误?暂时保持验证结果
|
||||
_updateCookieState(true, false); // Cookie 存在但保存失败,标记无效可能更安全
|
||||
throw new Error("保存 Bilibili Cookie 失败"); // 向上抛出错误
|
||||
}
|
||||
} else {
|
||||
// 新 Cookie 无效,不保存,并标记状态
|
||||
_updateCookieState(hasBiliCookie.value, false); // 保持 hasBiliCookie 原样或设为 false?取决于策略
|
||||
warn('[BiliCookie] 尝试设置的 Bilibili Cookie 无效,未保存');
|
||||
// 可以选择抛出错误,让调用者知道设置失败
|
||||
// throw new Error("设置的 Bilibili Cookie 无效");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description 获取当前存储的 Bilibili Cookie (不保证有效性)
|
||||
* @returns Promise<string | undefined> Cookie 字符串或 undefined
|
||||
*/
|
||||
const getBiliCookie = async (): Promise<string | undefined> => {
|
||||
const data = await biliCookieStore.get();
|
||||
return data?.cookie;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description 退出登录,清除 Bilibili Cookie 及相关状态和缓存
|
||||
*/
|
||||
const logout = async (): Promise<void> => {
|
||||
info('[BiliCookie] 用户请求退出登录...');
|
||||
// 停止定时检查器
|
||||
if (_checkIntervalId) {
|
||||
clearInterval(_checkIntervalId);
|
||||
_checkIntervalId = null;
|
||||
debug('[BiliCookie] 定时检查已停止');
|
||||
}
|
||||
// 清除 Cookie 存储
|
||||
try {
|
||||
await biliCookieStore.delete();
|
||||
} catch (err) {
|
||||
error('[BiliCookie] 清除 Bilibili Cookie 存储失败: ' + String(err));
|
||||
}
|
||||
// 清除用户信息缓存
|
||||
await _clearUserInfoCache();
|
||||
// 重置状态变量
|
||||
_updateCookieState(false, false);
|
||||
// Cookie Cloud 状态是否重置?取决于产品逻辑,暂时保留
|
||||
// cookieCloudState.value = 'unset';
|
||||
// 重置初始化标志,允许重新 init
|
||||
_isInitialized = false;
|
||||
_checkCounter = 0; // 重置计数器
|
||||
info('[BiliCookie] 退出登录完成,状态已重置');
|
||||
};
|
||||
|
||||
/**
|
||||
* @description 设置并验证 CookieCloud 配置
|
||||
* @param config CookieCloud 配置数据
|
||||
* @throws 如果配置无效或从 CookieCloud 获取/验证 Cookie 失败
|
||||
*/
|
||||
const setCookieCloudConfig = async (config: CookieCloudConfig): Promise<void> => {
|
||||
info('[BiliCookie] 正在设置新的 CookieCloud 配置...');
|
||||
cookieCloudState.value = 'syncing'; // 标记为尝试同步/验证中
|
||||
|
||||
try {
|
||||
// 1. 使用新配置尝试从 Cloud 获取 Cookie
|
||||
const cookieString = await _fetchAndDecryptFromCloud(config);
|
||||
// 2. 验证获取到的 Cookie
|
||||
const validationResult = await _checkCookieValidity(cookieString);
|
||||
|
||||
if (validationResult.valid && validationResult.data) {
|
||||
// 3. 如果验证成功,保存 CookieCloud 配置
|
||||
await cookieCloudStore.set(config);
|
||||
info('[BiliCookie] CookieCloud 配置验证成功并已保存. 用户:' + validationResult.data.name);
|
||||
cookieCloudState.value = 'valid'; // 标记为有效
|
||||
|
||||
// 4. 使用从 Cloud 获取的有效 Cookie 更新本地 Cookie
|
||||
// 注意:这里直接调用 setBiliCookie 会再次进行验证,但确保状态一致性
|
||||
await setBiliCookie(cookieString);
|
||||
// 重置检查计数器,以便下次正常检查
|
||||
_checkCounter = 0;
|
||||
} else {
|
||||
// 从 Cloud 获取的 Cookie 无效
|
||||
cookieCloudState.value = 'invalid';
|
||||
warn('[BiliCookie] 使用新 CookieCloud 配置获取的 Cookie 无效');
|
||||
throw new Error('CookieCloud 配置无效:获取到的 Bilibili Cookie 无法通过验证');
|
||||
}
|
||||
} catch (err) {
|
||||
error('[BiliCookie] 设置 CookieCloud 配置失败: ' + String(err));
|
||||
cookieCloudState.value = 'invalid'; // 出错则标记为无效
|
||||
// 向上抛出错误,通知调用者失败
|
||||
throw err; // err 已经是 Error 类型或被包装过
|
||||
}
|
||||
};
|
||||
async function clearCookieCloudConfig() {
|
||||
info('[BiliCookie] 清除 CookieCloud 配置...');
|
||||
cookieCloudState.value = 'unset';
|
||||
// 清除持久化存储
|
||||
await cookieCloudStore.delete().catch(err => {
|
||||
error('[BiliCookie] 清除 CookieCloud 配置失败: ' + String(err));
|
||||
});
|
||||
}
|
||||
|
||||
// --- 返回 Store 的公开接口 ---
|
||||
return {
|
||||
// 只读状态和计算属性
|
||||
hasBiliCookie: computed(() => hasBiliCookie.value), // 只读 ref
|
||||
isCookieValid: computed(() => isCookieValid.value), // 只读 ref
|
||||
cookieCloudState: computed(() => cookieCloudState.value), // 只读 ref
|
||||
uId: computed(() => uId.value), // 只读 ref
|
||||
userInfo, // computed 属性本身就是只读的
|
||||
|
||||
// 方法
|
||||
init,
|
||||
check, // 暴露 check 方法,允许手动触发检查 (例如,应用从后台恢复)
|
||||
setBiliCookie,
|
||||
getBiliCookie, // 获取原始 cookie 字符串的方法
|
||||
logout,
|
||||
setCookieCloudConfig,
|
||||
clearCookieCloudConfig,
|
||||
// 注意:不再直接暴露 fetchBiliCookieFromCloud,其逻辑已整合到内部同步和设置流程中
|
||||
};
|
||||
});
|
||||
|
||||
// --- HMR 支持 ---
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept(acceptHMRUpdate(useBiliCookie, import.meta.hot));
|
||||
}
|
||||
45
src/client/store/useSettings.ts
Normal file
45
src/client/store/useSettings.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useTauriStore } from './useTauriStore';
|
||||
|
||||
export type NotificationType = 'question-box' | 'danmaku';
|
||||
export type NotificationSettings = {
|
||||
enableTypes: NotificationType[];
|
||||
};
|
||||
export type VTsuruClientSettings = {
|
||||
useDanmakuClientType: 'openlive' | 'direct';
|
||||
fallbackToOpenLive: boolean;
|
||||
|
||||
danmakuHistorySize: number;
|
||||
loginType: 'qrcode' | 'cookiecloud'
|
||||
|
||||
enableNotification: boolean;
|
||||
notificationSettings: NotificationSettings;
|
||||
};
|
||||
|
||||
export const useSettings = defineStore('settings', () => {
|
||||
const store = useTauriStore().getTarget<VTsuruClientSettings>('settings');
|
||||
const defaultSettings: VTsuruClientSettings = {
|
||||
useDanmakuClientType: 'openlive',
|
||||
fallbackToOpenLive: true,
|
||||
|
||||
danmakuHistorySize: 100,
|
||||
loginType: 'qrcode',
|
||||
enableNotification: true,
|
||||
notificationSettings: {
|
||||
enableTypes: ['question-box', 'danmaku'],
|
||||
},
|
||||
};
|
||||
const settings = ref<VTsuruClientSettings>(Object.assign({}, defaultSettings));
|
||||
|
||||
async function init() {
|
||||
settings.value = (await store.get()) || Object.assign({}, defaultSettings);
|
||||
settings.value.notificationSettings ??= defaultSettings.notificationSettings;
|
||||
settings.value.notificationSettings.enableTypes ??= [ 'question-box', 'danmaku' ];
|
||||
}
|
||||
async function save() {
|
||||
await store.set(settings.value);
|
||||
}
|
||||
|
||||
return { init, save, settings };
|
||||
});
|
||||
|
||||
if (import.meta.hot) import.meta.hot.accept(acceptHMRUpdate(useSettings, import.meta.hot));
|
||||
53
src/client/store/useTauriStore.ts
Normal file
53
src/client/store/useTauriStore.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { LazyStore } from '@tauri-apps/plugin-store';
|
||||
|
||||
export class StoreTarget<T> {
|
||||
constructor(key: string, target: LazyStore, defaultValue?: T) {
|
||||
this.target = target;
|
||||
this.key = key;
|
||||
this.defaultValue = defaultValue;
|
||||
}
|
||||
protected target: LazyStore;
|
||||
protected defaultValue: T | undefined;
|
||||
|
||||
protected key: string;
|
||||
|
||||
async set(value: T) {
|
||||
return await this.target.set(this.key, value);
|
||||
}
|
||||
async get(): Promise<T | undefined> {
|
||||
const result = await this.target.get<T>(this.key);
|
||||
|
||||
if (result === undefined && this.defaultValue !== undefined) {
|
||||
await this.set(this.defaultValue);
|
||||
return this.defaultValue as T;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async delete() {
|
||||
return await this.target.delete(this.key);
|
||||
}
|
||||
}
|
||||
|
||||
export const useTauriStore = defineStore('tauri', () => {
|
||||
const store = new LazyStore('vtsuru.data.json', {
|
||||
autoSave: true,
|
||||
});
|
||||
async function set(key: string, value: any) {
|
||||
await store.set(key, value);
|
||||
}
|
||||
async function get<T>(key: string) {
|
||||
return await store.get<T>(key);
|
||||
}
|
||||
function getTarget<T>(key: string, defaultValue?: T) {
|
||||
return new StoreTarget<T>(key, store, defaultValue);
|
||||
}
|
||||
return {
|
||||
store,
|
||||
set,
|
||||
get,
|
||||
getTarget,
|
||||
};
|
||||
});
|
||||
|
||||
if (import.meta.hot) import.meta.hot.accept(acceptHMRUpdate(useTauriStore, import.meta.hot));
|
||||
19
src/components.d.ts
vendored
19
src/components.d.ts
vendored
@@ -17,10 +17,29 @@ declare module 'vue' {
|
||||
FeedbackItem: typeof import('./components/FeedbackItem.vue')['default']
|
||||
LiveInfoContainer: typeof import('./components/LiveInfoContainer.vue')['default']
|
||||
MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default']
|
||||
NAlert: typeof import('naive-ui')['NAlert']
|
||||
NAvatar: typeof import('naive-ui')['NAvatar']
|
||||
NButton: typeof import('naive-ui')['NButton']
|
||||
NCard: typeof import('naive-ui')['NCard']
|
||||
NCheckbox: typeof import('naive-ui')['NCheckbox']
|
||||
NFlex: typeof import('naive-ui')['NFlex']
|
||||
NFormItemG: typeof import('naive-ui')['NFormItemG']
|
||||
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
|
||||
NGridItem: typeof import('naive-ui')['NGridItem']
|
||||
NH4: typeof import('naive-ui')['NH4']
|
||||
NIcon: typeof import('naive-ui')['NIcon']
|
||||
NImage: typeof import('naive-ui')['NImage']
|
||||
NInput: typeof import('naive-ui')['NInput']
|
||||
NLayoutContent: typeof import('naive-ui')['NLayoutContent']
|
||||
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
|
||||
NRadioButton: typeof import('naive-ui')['NRadioButton']
|
||||
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
|
||||
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||
NSpace: typeof import('naive-ui')['NSpace']
|
||||
NTab: typeof import('naive-ui')['NTab']
|
||||
NTag: typeof import('naive-ui')['NTag']
|
||||
NText: typeof import('naive-ui')['NText']
|
||||
NTooltip: typeof import('naive-ui')['NTooltip']
|
||||
PointGoodsItem: typeof import('./components/manage/PointGoodsItem.vue')['default']
|
||||
PointHistoryCard: typeof import('./components/manage/PointHistoryCard.vue')['default']
|
||||
PointOrderCard: typeof import('./components/manage/PointOrderCard.vue')['default']
|
||||
|
||||
@@ -15,6 +15,7 @@ onMounted(() => {
|
||||
window.$message = useMessage()
|
||||
window.$route = useRoute()
|
||||
window.$modal = useModal()
|
||||
window.$notification = useNotification()
|
||||
const providerStore = useLoadingBarStore()
|
||||
providerStore.setLoadingBar(window.$loadingBar)
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ export default abstract class BaseDanmakuClient {
|
||||
'padding'
|
||||
|
||||
public abstract type: 'openlive' | 'direct'
|
||||
public abstract serverUrl: string
|
||||
|
||||
public eventsAsModel: {
|
||||
danmaku: ((arg1: EventModel, arg2?: any) => void)[]
|
||||
@@ -118,6 +119,7 @@ export default abstract class BaseDanmakuClient {
|
||||
this.client.close()
|
||||
this.client = null
|
||||
}
|
||||
this.serverUrl = chatClient.connection.ws.ws.url
|
||||
return {
|
||||
success: !isError,
|
||||
message: errorMsg
|
||||
|
||||
@@ -11,6 +11,7 @@ export type DirectClientAuthInfo = {
|
||||
* 未实现除raw事件外的所有事件
|
||||
*/
|
||||
export default class DirectClient extends BaseDanmakuClient {
|
||||
public serverUrl: string = 'wss://broadcastlv.chat.bilibili.com/sub';
|
||||
public onDanmaku(command: any): void {
|
||||
throw new Error('Method not implemented.')
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { OPEN_LIVE_API_URL } from '../constants'
|
||||
import BaseDanmakuClient from './BaseDanmakuClient'
|
||||
|
||||
export default class OpenLiveClient extends BaseDanmakuClient {
|
||||
public serverUrl: string = '';
|
||||
constructor(auth?: AuthInfo) {
|
||||
super()
|
||||
this.authInfo = auth
|
||||
|
||||
@@ -14,6 +14,10 @@ import { useAuthStore } from './store/useAuthStore'
|
||||
import { useNotificationStore } from './store/useNotificationStore'
|
||||
|
||||
const pinia = createPinia()
|
||||
export const getPinia = () => pinia
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router).use(pinia).mount('#app')
|
||||
|
||||
QueryGetAPI<string>(`${BASE_API_URL}vtsuru/version`)
|
||||
.then((version) => {
|
||||
@@ -122,9 +126,6 @@ QueryGetAPI<string>(`${BASE_API_URL}vtsuru/version`)
|
||||
UpdateAccountLoop()
|
||||
})
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router).use(pinia).mount('#app')
|
||||
|
||||
let currentVersion: string
|
||||
let isHaveNewVersion = false
|
||||
|
||||
|
||||
38
src/router/client.ts
Normal file
38
src/router/client.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export default {
|
||||
path: '/client',
|
||||
name: 'client',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'client-index',
|
||||
component: () => import('@/client/ClientIndex.vue'),
|
||||
meta: {
|
||||
title: '首页',
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'fetcher',
|
||||
name: 'client-fetcher',
|
||||
component: () => import('@/client/ClientFetcher.vue'),
|
||||
meta: {
|
||||
title: 'EventFetcher',
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'client-settings',
|
||||
component: () => import('@/client/ClientSettings.vue'),
|
||||
meta: {
|
||||
title: '设置',
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'test',
|
||||
name: 'client-test',
|
||||
component: () => import('@/client/ClientTest.vue'),
|
||||
meta: {
|
||||
title: '测试',
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import user from './user'
|
||||
import obs from './obs'
|
||||
import open_live from './open_live'
|
||||
import singlePage from './singlePage'
|
||||
import client from './client';
|
||||
|
||||
const routes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
@@ -88,6 +89,7 @@ const routes: Array<RouteRecordRaw> = [
|
||||
manage,
|
||||
obs,
|
||||
open_live,
|
||||
client,
|
||||
{
|
||||
path: '/@:id',
|
||||
name: 'user',
|
||||
|
||||
@@ -1,270 +1,402 @@
|
||||
import { BASE_HUB_URL } 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 { useLocalStorage } from '@vueuse/core'
|
||||
import { format } from 'date-fns'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { compress } from 'brotli-compress'
|
||||
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 * as signalR from '@microsoft/signalr';
|
||||
import * as msgpack from '@microsoft/signalr-protocol-msgpack';
|
||||
import { 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 { useWebRTC } from './useRTC';
|
||||
|
||||
export const useWebFetcher = defineStore('WebFetcher', () => {
|
||||
const cookie = useLocalStorage('JWT_Token', '')
|
||||
const route = useRoute()
|
||||
const startedAt = ref<Date>()
|
||||
const cookie = useLocalStorage('JWT_Token', '');
|
||||
const route = useRoute();
|
||||
const account = useAccount();
|
||||
const rtc = useWebRTC();
|
||||
const webfetcherType = ref<'openlive' | 'direct'>('openlive'); // 弹幕客户端类型
|
||||
// --- 连接与状态 ---
|
||||
const state = ref<'disconnected' | 'connecting' | 'connected'>('disconnected'); // SignalR 连接状态
|
||||
const startedAt = ref<Date>(); // 本次启动时间
|
||||
const signalRClient = shallowRef<signalR.HubConnection>(); // SignalR 客户端实例 (浅响应)
|
||||
const client = shallowRef<BaseDanmakuClient>(); // 弹幕客户端实例 (浅响应)
|
||||
let timer: any; // 事件发送定时器
|
||||
let disconnectedByServer = false;
|
||||
let isFromClient = false; // 是否由Tauri客户端启动
|
||||
|
||||
const client = ref<BaseDanmakuClient>()
|
||||
const signalRClient = ref<signalR.HubConnection>()
|
||||
// --- 新增: 详细状态与信息 ---
|
||||
/** 弹幕客户端内部状态 */
|
||||
const danmakuClientState = ref<'stopped' | 'connecting' | 'connected'>('stopped'); // 更详细的弹幕客户端状态
|
||||
/** 弹幕服务器连接地址 */
|
||||
const danmakuServerUrl = ref<string>();
|
||||
/** SignalR 连接 ID */
|
||||
const signalRConnectionId = ref<string>();
|
||||
// const heartbeatLatency = ref<number>(null); // 心跳延迟暂不实现,复杂度较高
|
||||
|
||||
// --- 事件处理 ---
|
||||
const events: string[] = []; // 待发送事件队列
|
||||
|
||||
// --- 新增: 会话统计 (在 Start 时重置) ---
|
||||
/** 本次会话处理的总事件数 */
|
||||
const sessionEventCount = ref(0);
|
||||
/** 本次会话各类型事件计数 */
|
||||
const sessionEventTypeCounts = ref<{ [key: string]: number; }>({});
|
||||
/** 本次会话成功上传次数 */
|
||||
const successfulUploads = ref(0);
|
||||
/** 本次会话失败上传次数 */
|
||||
const failedUploads = ref(0);
|
||||
/** 本次会话发送的总字节数 (压缩后) */
|
||||
const bytesSentSession = ref(0);
|
||||
|
||||
const prefix = computed(() => isFromClient ? '[web-fetcher-iframe] ' : '[web-fetcher] ');
|
||||
|
||||
const events: string[] = []
|
||||
const isStarted = ref(false)
|
||||
let timer: any
|
||||
let disconnectedByServer = false
|
||||
let useCookie = false
|
||||
/**
|
||||
* 是否来自Tauri客户端
|
||||
* 启动 WebFetcher 服务
|
||||
*/
|
||||
let isFromClient = false
|
||||
const prefix = computed(() => {
|
||||
if (isFromClient) {
|
||||
return '[web-fetcher-iframe] '
|
||||
}
|
||||
return '[web-fetcher] '
|
||||
})
|
||||
async function restartDanmakuClient(
|
||||
type: 'openlive' | 'direct',
|
||||
directAuthInfo?: DirectClientAuthInfo
|
||||
) {
|
||||
console.log(prefix.value + '正在重启弹幕客户端...')
|
||||
if (
|
||||
client.value?.state === 'connected' ||
|
||||
client.value?.state === 'connecting'
|
||||
) {
|
||||
client.value.Stop()
|
||||
}
|
||||
return await connectDanmakuClient(type, directAuthInfo)
|
||||
}
|
||||
async function Start(
|
||||
type: 'openlive' | 'direct' = 'openlive',
|
||||
directAuthInfo?: DirectClientAuthInfo,
|
||||
_isFromClient: boolean = false
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
if (isStarted.value) {
|
||||
startedAt.value = new Date()
|
||||
return { success: true, message: '已启动' }
|
||||
): Promise<{ success: boolean; message: string; }> {
|
||||
if (state.value === 'connected' || state.value === 'connecting') {
|
||||
logInfo(prefix.value + '已经启动,无需重复启动');
|
||||
return { success: true, message: '已启动' };
|
||||
}
|
||||
const result = await navigator.locks.request(
|
||||
'webFetcherStart',
|
||||
async () => {
|
||||
isFromClient = _isFromClient
|
||||
while (!(await connectSignalR())) {
|
||||
console.log(prefix.value + '连接失败, 5秒后重试')
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000))
|
||||
}
|
||||
let result = await connectDanmakuClient(type, directAuthInfo)
|
||||
while (!result?.success) {
|
||||
console.log(prefix.value + '弹幕客户端启动失败, 5秒后重试')
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000))
|
||||
result = await connectDanmakuClient(type, directAuthInfo)
|
||||
}
|
||||
isStarted.value = true
|
||||
disconnectedByServer = false
|
||||
return result
|
||||
webfetcherType.value = type; // 设置弹幕客户端类型
|
||||
// 重置会话统计数据
|
||||
resetSessionStats();
|
||||
startedAt.value = new Date();
|
||||
isFromClient = _isFromClient;
|
||||
state.value = 'connecting'; // 设置为连接中状态
|
||||
|
||||
// 使用 navigator.locks 确保同一时间只有一个 Start 操作执行
|
||||
const result = await navigator.locks.request('webFetcherStartLock', async () => {
|
||||
logInfo(prefix.value + '开始启动...');
|
||||
while (!(await connectSignalR())) {
|
||||
logInfo(prefix.value + '连接 SignalR 失败, 5秒后重试');
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
// 如果用户手动停止,则退出重试循环
|
||||
if (state.value === 'disconnected') return { success: false, message: '用户手动停止' };
|
||||
}
|
||||
)
|
||||
return result
|
||||
}
|
||||
function Stop() {
|
||||
if (!isStarted.value) {
|
||||
return
|
||||
|
||||
let danmakuResult = await connectDanmakuClient(type, directAuthInfo);
|
||||
while (!danmakuResult?.success) {
|
||||
logInfo(prefix.value + '弹幕客户端启动失败, 5秒后重试');
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
// 如果用户手动停止,则退出重试循环
|
||||
if (state.value === 'disconnected') return { success: false, message: '用户手动停止' };
|
||||
danmakuResult = await connectDanmakuClient(type, directAuthInfo);
|
||||
}
|
||||
|
||||
// 只有在两个连接都成功后才设置为 connected
|
||||
state.value = 'connected';
|
||||
disconnectedByServer = false;
|
||||
logInfo(prefix.value + '启动成功');
|
||||
return { success: true, message: '启动成功' };
|
||||
});
|
||||
|
||||
// 如果启动过程中因为手动停止而失败,需要确保状态是 disconnected
|
||||
if (!result.success) {
|
||||
Stop(); // 确保清理资源
|
||||
return { success: false, message: result.message || '启动失败' };
|
||||
}
|
||||
isStarted.value = false
|
||||
client.value?.Stop()
|
||||
client.value = undefined
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = undefined
|
||||
}
|
||||
signalRClient.value?.stop()
|
||||
signalRClient.value = undefined
|
||||
startedAt.value = undefined
|
||||
|
||||
return result;
|
||||
}
|
||||
/************* ✨ Codeium Command ⭐ *************/
|
||||
|
||||
/**
|
||||
* Connects to the danmaku client based on the specified type.
|
||||
*
|
||||
* @param type - The type of danmaku client to connect, either 'openlive' or 'direct'.
|
||||
* @param directConnectInfo - Optional authentication information required when connecting to a 'direct' type client.
|
||||
* It should include a token, roomId, tokenUserId, and buvid.
|
||||
*
|
||||
* @returns A promise that resolves to an object containing a success flag and a message.
|
||||
* If the connection and client start are successful, the client starts listening to danmaku events.
|
||||
* If the connection fails or the authentication information is not provided for a 'direct' type client,
|
||||
* the function returns with a failure message.
|
||||
* 停止 WebFetcher 服务
|
||||
*/
|
||||
function Stop() {
|
||||
if (state.value === 'disconnected') return;
|
||||
|
||||
logInfo(prefix.value + '正在停止...');
|
||||
state.value = 'disconnected'; // 立即设置状态,防止重连逻辑触发
|
||||
|
||||
// 清理定时器
|
||||
if (timer) { clearInterval(timer); timer = undefined; }
|
||||
|
||||
// 停止弹幕客户端
|
||||
client.value?.Stop();
|
||||
client.value = undefined;
|
||||
danmakuClientState.value = 'stopped';
|
||||
danmakuServerUrl.value = undefined;
|
||||
|
||||
// 停止 SignalR 连接
|
||||
signalRClient.value?.stop();
|
||||
signalRClient.value = undefined;
|
||||
signalRConnectionId.value = undefined;
|
||||
|
||||
// 清理状态
|
||||
startedAt.value = undefined;
|
||||
events.length = 0; // 清空事件队列
|
||||
// resetSessionStats(); // 会话统计在下次 Start 时重置
|
||||
|
||||
logInfo(prefix.value + '已停止');
|
||||
}
|
||||
|
||||
/** 重置会话统计数据 */
|
||||
function resetSessionStats() {
|
||||
sessionEventCount.value = 0;
|
||||
sessionEventTypeCounts.value = {};
|
||||
successfulUploads.value = 0;
|
||||
failedUploads.value = 0;
|
||||
bytesSentSession.value = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接弹幕客户端
|
||||
*/
|
||||
/****** 3431380f-29f6-41b0-801a-7f081b59b4ff *******/
|
||||
async function connectDanmakuClient(
|
||||
type: 'openlive' | 'direct',
|
||||
directConnectInfo?: {
|
||||
token: string
|
||||
roomId: number
|
||||
tokenUserId: number
|
||||
buvid: string
|
||||
}
|
||||
directConnectInfo?: DirectClientAuthInfo
|
||||
) {
|
||||
if (
|
||||
client.value?.state === 'connected' ||
|
||||
client.value?.state === 'connecting'
|
||||
) {
|
||||
return { success: true, message: '弹幕客户端已启动' }
|
||||
if (client.value?.state === 'connected' || client.value?.state === 'connecting') {
|
||||
logInfo(prefix.value + '弹幕客户端已连接或正在连接');
|
||||
return { success: true, message: '弹幕客户端已启动' };
|
||||
}
|
||||
console.log(prefix.value + '正在连接弹幕客户端...')
|
||||
|
||||
logInfo(prefix.value + '正在连接弹幕客户端...');
|
||||
danmakuClientState.value = 'connecting';
|
||||
|
||||
// 如果实例存在但已停止,先清理
|
||||
if (client.value?.state === 'disconnected') {
|
||||
client.value = undefined;
|
||||
}
|
||||
|
||||
// 创建实例并添加事件监听 (仅在首次创建时)
|
||||
if (!client.value) {
|
||||
//只有在没有客户端的时候才创建, 并添加事件
|
||||
if (type == 'openlive') {
|
||||
client.value = new OpenLiveClient()
|
||||
if (type === 'openlive') {
|
||||
client.value = new OpenLiveClient();
|
||||
} else {
|
||||
if (!directConnectInfo) {
|
||||
return { success: false, message: '未提供弹幕客户端认证信息' }
|
||||
danmakuClientState.value = 'stopped';
|
||||
logError(prefix.value + '未提供直连弹幕客户端认证信息');
|
||||
return { success: false, message: '未提供弹幕客户端认证信息' };
|
||||
}
|
||||
client.value = new DirectClient(directConnectInfo)
|
||||
client.value = new DirectClient(directConnectInfo);
|
||||
// 直连地址通常包含 host 和 port,可以从 directConnectInfo 获取
|
||||
//danmakuServerUrl.value = `${directConnectInfo.host}:${directConnectInfo.port}`;
|
||||
}
|
||||
|
||||
client.value?.on('all', (data) => onGetDanmakus(data))
|
||||
// 监听所有事件,用于处理和转发
|
||||
client.value?.on('all', onGetDanmakus);
|
||||
}
|
||||
|
||||
const result = await client.value?.Start()
|
||||
|
||||
// 启动客户端连接
|
||||
const result = await client.value?.Start();
|
||||
|
||||
if (result?.success) {
|
||||
console.log(prefix.value + '加载完成, 开始监听弹幕')
|
||||
timer ??= setInterval(() => {
|
||||
sendEvents()
|
||||
}, 1500)
|
||||
logInfo(prefix.value + '弹幕客户端连接成功, 开始监听弹幕');
|
||||
danmakuClientState.value = 'connected'; // 明确设置状态
|
||||
danmakuServerUrl.value = client.value.serverUrl; // 获取服务器地址
|
||||
// 启动事件发送定时器 (如果之前没有启动)
|
||||
timer ??= setInterval(sendEvents, 1500); // 每 1.5 秒尝试发送一次事件
|
||||
} else {
|
||||
console.log(prefix.value + '弹幕客户端启动失败: ' + result?.message)
|
||||
logError(prefix.value + '弹幕客户端启动失败: ' + result?.message);
|
||||
danmakuClientState.value = 'stopped';
|
||||
danmakuServerUrl.value = undefined;
|
||||
client.value = undefined; // 启动失败,清理实例,下次会重建
|
||||
}
|
||||
return result
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接 SignalR 服务器
|
||||
*/
|
||||
async function connectSignalR() {
|
||||
console.log(prefix.value + '正在连接到 vtsuru 服务器...')
|
||||
if (signalRClient.value && signalRClient.value.state !== signalR.HubConnectionState.Disconnected) {
|
||||
logInfo(prefix.value + "SignalR 已连接或正在连接");
|
||||
return true;
|
||||
}
|
||||
|
||||
logInfo(prefix.value + '正在连接到 vtsuru 服务器...');
|
||||
const connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl(BASE_HUB_URL + 'web-fetcher?token=' + route.query.token, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${cookie.value}`
|
||||
},
|
||||
.withUrl(BASE_HUB_URL + 'web-fetcher?token=' + (route.query.token ?? account.value.token), { // 使用 account.token
|
||||
headers: { Authorization: `Bearer ${cookie.value}` },
|
||||
skipNegotiation: true,
|
||||
transport: signalR.HttpTransportType.WebSockets
|
||||
})
|
||||
.withAutomaticReconnect([0, 2000, 10000, 30000])
|
||||
.withHubProtocol(new msgpack.MessagePackHubProtocol())
|
||||
.build()
|
||||
connection.on('Disconnect', (reason: unknown) => {
|
||||
console.log(prefix.value + '被服务器断开连接: ' + reason)
|
||||
disconnectedByServer = true
|
||||
connection.stop()
|
||||
signalRClient.value = undefined
|
||||
})
|
||||
/*connection.on('ConnectClient', async () => {
|
||||
if (client?.state === 'connected') {
|
||||
return
|
||||
}
|
||||
let result = await connectDanmakuClient()
|
||||
while (!result?.success) {
|
||||
console.log(prefix.value + '弹幕客户端启动失败, 5秒后重试')
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000))
|
||||
result = await connectDanmakuClient()
|
||||
}
|
||||
isStarted.value = true
|
||||
disconnectedByServer = false
|
||||
})*/
|
||||
.withAutomaticReconnect([0, 2000, 10000, 30000]) // 自动重连策略
|
||||
.withHubProtocol(new msgpack.MessagePackHubProtocol()) // 使用 MessagePack 协议
|
||||
.build();
|
||||
|
||||
connection.onclose(reconnect)
|
||||
try {
|
||||
await connection.start()
|
||||
console.log(prefix.value + '已连接到 vtsuru 服务器')
|
||||
await connection.send('Finished')
|
||||
// --- SignalR 事件监听 ---
|
||||
connection.onreconnecting(error => {
|
||||
logInfo(prefix.value + `与服务器断开,正在尝试重连... ${error?.message || ''}`);
|
||||
state.value = 'connecting'; // 更新状态为连接中
|
||||
signalRConnectionId.value = undefined; // 连接断开,ID失效
|
||||
});
|
||||
|
||||
connection.onreconnected(connectionId => {
|
||||
logInfo(prefix.value + `与服务器重新连接成功! ConnectionId: ${connectionId}`);
|
||||
signalRConnectionId.value = connectionId ?? undefined;
|
||||
state.value = 'connected'; // 更新状态为已连接
|
||||
// 重连成功后可能需要重新发送标识
|
||||
if (isFromClient) {
|
||||
// 如果来自Tauri客户端,设置自己为VTsuru客户端
|
||||
await connection.send('SetAsVTsuruClient')
|
||||
connection.send('SetAsVTsuruClient').catch(err => logError(prefix.value + "Send SetAsVTsuruClient failed: " + err));
|
||||
}
|
||||
signalRClient.value = connection
|
||||
return true
|
||||
} catch (e) {
|
||||
console.log(prefix.value + '无法连接到 vtsuru 服务器: ' + e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
async function reconnect() {
|
||||
if (disconnectedByServer) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await signalRClient.value?.start()
|
||||
await signalRClient.value?.send('Reconnected')
|
||||
if (isFromClient) {
|
||||
await signalRClient.value?.send('SetAsVTsuruClient')
|
||||
}
|
||||
console.log(prefix.value + '已重新连接')
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
setTimeout(reconnect, 5000) // 如果连接失败,则每5秒尝试一次重新启动连接
|
||||
}
|
||||
}
|
||||
function onGetDanmakus(command: any) {
|
||||
events.push(command)
|
||||
}
|
||||
async function sendEvents() {
|
||||
if (signalRClient.value?.state !== 'Connected') {
|
||||
return
|
||||
}
|
||||
let tempEvents: string[] = []
|
||||
let count = events.length
|
||||
if (events.length > 20) {
|
||||
tempEvents = events.slice(0, 20)
|
||||
count = 20
|
||||
} else {
|
||||
tempEvents = events
|
||||
count = events.length
|
||||
}
|
||||
if (tempEvents.length > 0) {
|
||||
const compressed = await compress(
|
||||
new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
Events: tempEvents.map((e) =>
|
||||
typeof e === 'string' ? e : JSON.stringify(e)
|
||||
),
|
||||
Version: '1.0.0',
|
||||
OSInfo: navigator.userAgent,
|
||||
UseCookie: useCookie
|
||||
})
|
||||
)
|
||||
)
|
||||
const result = await signalRClient.value?.invoke<{
|
||||
Success: boolean
|
||||
Message: string
|
||||
}>('UploadEventsCompressed', compressed)
|
||||
if (result?.Success) {
|
||||
events.splice(0, count)
|
||||
console.log(
|
||||
`[WEB-FETCHER] <${format(new Date(), 'HH:mm:ss')}> 上传了 ${count} 条弹幕`
|
||||
)
|
||||
connection.send('Reconnected').catch(err => logError(prefix.value + "Send Reconnected failed: " + err));
|
||||
});
|
||||
|
||||
connection.onclose(async (error) => {
|
||||
// 只有在不是由 Stop() 或服务器明确要求断开时才记录错误并尝试独立重连(虽然 withAutomaticReconnect 应该处理)
|
||||
if (state.value !== 'disconnected' && !disconnectedByServer) {
|
||||
logError(prefix.value + `与服务器连接关闭: ${error?.message || '未知原因'}. 自动重连将处理.`);
|
||||
state.value = 'connecting'; // 标记为连接中,等待自动重连
|
||||
signalRConnectionId.value = undefined;
|
||||
// withAutomaticReconnect 会处理重连,这里不需要手动调用 reconnect
|
||||
} else if (disconnectedByServer) {
|
||||
logInfo(prefix.value + `连接已被服务器关闭.`);
|
||||
Stop(); // 服务器要求断开,则彻底停止
|
||||
} else {
|
||||
console.error(prefix.value + '上传弹幕失败: ' + result?.Message)
|
||||
logInfo(prefix.value + `连接已手动关闭.`);
|
||||
}
|
||||
});
|
||||
|
||||
connection.on('Disconnect', (reason: unknown) => {
|
||||
logInfo(prefix.value + '被服务器断开连接: ' + reason);
|
||||
disconnectedByServer = true; // 标记是服务器主动断开
|
||||
Stop(); // 服务器要求断开,调用 Stop 清理所有资源
|
||||
});
|
||||
|
||||
// --- 尝试启动连接 ---
|
||||
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
|
||||
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);
|
||||
signalRConnectionId.value = undefined;
|
||||
signalRClient.value = undefined;
|
||||
// state.value = 'disconnected'; // 保持 connecting 或由 Start 控制
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// async function reconnect() { // withAutomaticReconnect 存在时,此函数通常不需要手动调用
|
||||
// if (disconnectedByServer || state.value === 'disconnected') return;
|
||||
// logInfo(prefix.value + '尝试手动重连...');
|
||||
// try {
|
||||
// await signalRClient.value?.start();
|
||||
// logInfo(prefix.value + '手动重连成功');
|
||||
// signalRConnectionId.value = signalRClient.value?.connectionId ?? null;
|
||||
// state.value = 'connected';
|
||||
// if (isFromClient) {
|
||||
// await signalRClient.value?.send('SetAsVTsuruClient');
|
||||
// }
|
||||
// await signalRClient.value?.send('Reconnected');
|
||||
// } catch (err) {
|
||||
// logError(prefix.value + '手动重连失败: ' + err);
|
||||
// setTimeout(reconnect, 10000); // 失败后10秒再次尝试
|
||||
// }
|
||||
// }
|
||||
|
||||
/**
|
||||
* 接收到弹幕事件时的处理函数
|
||||
*/
|
||||
function onGetDanmakus(command: any) {
|
||||
if (isFromClient) {
|
||||
// 1. 解析事件类型
|
||||
const eventType = getEventType(command);
|
||||
|
||||
// 2. 记录到每日统计 (调用 statistics 模块)
|
||||
recordEvent(eventType);
|
||||
|
||||
// 3. 更新会话统计
|
||||
sessionEventCount.value++;
|
||||
sessionEventTypeCounts.value[eventType] = (sessionEventTypeCounts.value[eventType] || 0) + 1;
|
||||
}
|
||||
// 4. 加入待发送队列 (确保是字符串)
|
||||
const eventString = typeof command === 'string' ? command : JSON.stringify(command);
|
||||
if (isDev) {
|
||||
//console.log(prefix.value + '收到弹幕事件: ' + eventString); // 开发模式下打印所有事件 (可选)
|
||||
}
|
||||
if (events.length >= 10000) {
|
||||
events.shift(); // 如果队列过长,移除最旧的事件
|
||||
}
|
||||
events.push(eventString);
|
||||
}
|
||||
|
||||
/**
|
||||
* 定期将队列中的事件发送到服务器
|
||||
*/
|
||||
async function sendEvents() {
|
||||
// 确保 SignalR 已连接
|
||||
if (!signalRClient.value || signalRClient.value.state !== signalR.HubConnectionState.Connected) {
|
||||
return;
|
||||
}
|
||||
// 如果没有事件,则不发送
|
||||
if (events.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 批量处理事件,每次最多发送20条
|
||||
const batchSize = 20;
|
||||
const batch = events.slice(0, batchSize);
|
||||
|
||||
try {
|
||||
|
||||
const 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;
|
||||
} else {
|
||||
failedUploads.value++;
|
||||
logError(prefix.value + '上传弹幕失败: ' + result?.Message);
|
||||
}
|
||||
} catch (err) {
|
||||
failedUploads.value++;
|
||||
logError(prefix.value + '发送事件时出错: ' + err);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 暴露给外部使用的状态和方法 ---
|
||||
return {
|
||||
Start,
|
||||
Stop,
|
||||
restartDanmakuClient,
|
||||
client,
|
||||
signalRClient,
|
||||
isStarted,
|
||||
startedAt
|
||||
}
|
||||
})
|
||||
// restartDanmakuClient, // 如果需要重启单独的弹幕客户端,可以保留或实现
|
||||
|
||||
// 状态
|
||||
state, // Overall SignalR state
|
||||
startedAt,
|
||||
isStreaming: computed(() => streamingInfo.value?.status === 'streaming'), // 从 statistics 模块获取
|
||||
webfetcherType,
|
||||
|
||||
// 连接详情
|
||||
danmakuClientState,
|
||||
danmakuServerUrl,
|
||||
//signalRConnectionId,
|
||||
// heartbeatLatency, // 暂不暴露
|
||||
|
||||
// 会话统计
|
||||
sessionEventCount,
|
||||
sessionEventTypeCounts,
|
||||
successfulUploads,
|
||||
failedUploads,
|
||||
bytesSentSession,
|
||||
|
||||
// 实例 (谨慎暴露,主要用于调试或特定场景)
|
||||
signalRClient: computed(() => signalRClient.value), // 返回计算属性以防直接修改
|
||||
client: computed(() => client.value),
|
||||
|
||||
};
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useAccount } from '@/api/account';
|
||||
import { useWebFetcher } from '@/store/useWebFetcher';
|
||||
import {fetch} from "@tauri-apps/plugin-http";
|
||||
import { NFlex } from 'naive-ui';
|
||||
|
||||
const webfetcher = useWebFetcher();
|
||||
const accountInfo = useAccount();
|
||||
|
||||
function initWebfetcher() {
|
||||
webfetcher.Start();
|
||||
webfetcher.signalRClient?.send();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (accountInfo.value.id) {
|
||||
initWebfetcher();
|
||||
console.info('WebFetcher started')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NFlex v-if="!accountInfo.id">
|
||||
<RegisterAndLogin />
|
||||
</NFlex>
|
||||
<NFlex v-else>
|
||||
{{ webfetcher }}
|
||||
</NFlex>
|
||||
</template>
|
||||
@@ -31,7 +31,7 @@ onMounted(async () => {
|
||||
|
||||
rpc.expose('status', () => {
|
||||
return {
|
||||
status: webFetcher.isStarted ? 'running' : 'stopped',
|
||||
status: webFetcher.state,
|
||||
type: webFetcher.client?.type,
|
||||
roomId: webFetcher.client instanceof OpenLiveClient ?
|
||||
webFetcher.client.roomAuthInfo?.anchor_info.room_id :
|
||||
@@ -44,7 +44,8 @@ onMounted(async () => {
|
||||
|
||||
rpc.expose('start', async (data: { type: 'openlive' | 'direct', directAuthInfo?: DirectClientAuthInfo, force: boolean }) => {
|
||||
console.log('[web-fetcher-iframe] 接收到 ' + (data.force ? '强制' : '') + '启动请求')
|
||||
if (data.force && webFetcher.isStarted) {
|
||||
if (data.force && webFetcher.state === 'connected') {
|
||||
console.log('[web-fetcher-iframe] 强制启动, 停止当前实例')
|
||||
webFetcher.Stop()
|
||||
}
|
||||
return await webFetcher.Start(data.type, data.directAuthInfo, true).then((result) => {
|
||||
@@ -73,9 +74,9 @@ onMounted(async () => {
|
||||
}
|
||||
setTimeout(() => {
|
||||
// @ts-expect-error obs的东西
|
||||
if (!webFetcher.isStarted && window.obsstudio) {
|
||||
if (webFetcher.state !== 'connected' && window.obsstudio) {
|
||||
timer = setInterval(() => {
|
||||
if (webFetcher.isStarted) {
|
||||
if (webFetcher.state === 'connected') {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -99,7 +100,7 @@ onUnmounted(() => {
|
||||
<div
|
||||
class="web-fetcher-status"
|
||||
:style="{
|
||||
backgroundColor: webFetcher.isStarted ? '#6dc56d' : '#e34a4a',
|
||||
backgroundColor: webFetcher.state === 'connected' ? '#6dc56d' : '#e34a4a',
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -644,8 +644,9 @@ export const Config = defineTemplateConfig([
|
||||
</n-flex>
|
||||
|
||||
<!-- Song Table -->
|
||||
<div
|
||||
<NScrollbar
|
||||
class="song-table-wrapper"
|
||||
trigger="none"
|
||||
:style="{ height: props.config?.fixedHeight ? '55vh' : 'none' }"
|
||||
>
|
||||
<table class="song-list-table">
|
||||
@@ -739,7 +740,7 @@ export const Config = defineTemplateConfig([
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</NScrollbar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1116,7 +1117,7 @@ html.dark .song-list-container {
|
||||
/* min-height: 200px; */ /* Might not be needed if max-height is set */
|
||||
border-radius: 8px;
|
||||
/* Scrollbar styling specific to this inner table scroll if needed */
|
||||
/* ... */
|
||||
scroll-behavior: smooth;
|
||||
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user