添加客户端

This commit is contained in:
2025-04-06 13:50:16 +08:00
parent 4476be60b5
commit d5c9e663da
32 changed files with 4462 additions and 443 deletions

BIN
bun.lockb

Binary file not shown.

3
default.d.ts vendored
View File

@@ -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
}
}

View File

@@ -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",

View File

@@ -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>

View File

@@ -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

File diff suppressed because it is too large Load Diff

143
src/client/ClientIndex.vue Normal file
View 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
View 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>

View 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
View 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
View 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>

View 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
View 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);
}
}

View 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
View 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;
};

View 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
View 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() || '未知错误',
};
}
}

View 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));
}

View 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));

View 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
View File

@@ -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']

View File

@@ -15,6 +15,7 @@ onMounted(() => {
window.$message = useMessage()
window.$route = useRoute()
window.$modal = useModal()
window.$notification = useNotification()
const providerStore = useLoadingBarStore()
providerStore.setLoadingBar(window.$loadingBar)
})

View File

@@ -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

View File

@@ -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.')
}

View File

@@ -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

View File

@@ -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
View 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: '测试',
}
},
]
}

View File

@@ -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',

View File

@@ -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),
};
});

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
}