feat: 优化弹幕窗口样式系统并添加 Cookie 状态监控

- 将 naive-ui 版本约束从固定版本改为 ^ 范围约束
- 重构弹幕窗口背景渲染:分离窗口背景和弹幕背景,支持独立配置
- 新增颜色解析工具函数 parseColorToRgba,支持 hex/rgba 格式及透明度提取
- 优化 CSS 变量系统:添加 --dw-bg-color-rgb、--dw-bg-alpha、--dw-window-bg-color 变量
- 改进弹幕窗口布局:使用独立背景层支持 backdrop-filter 和圆角边框
- 在
This commit is contained in:
2025-11-26 14:42:56 +08:00
parent 9d0ea6c591
commit 6bccb4a0f4
9 changed files with 319 additions and 34 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -53,7 +53,7 @@
"md5": "^2.3.0", "md5": "^2.3.0",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"monaco-editor": "^0.54.0", "monaco-editor": "^0.54.0",
"naive-ui": "2.43.2", "naive-ui": "^2.43.2",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"obs-websocket-js": "^5.0.7", "obs-websocket-js": "^5.0.7",
"peerjs": "^1.5.5", "peerjs": "^1.5.5",

View File

@@ -15,6 +15,70 @@ type TempDanmakuType = EventModel & {
timestamp?: number // 添加:记录插入时间戳 timestamp?: number // 添加:记录插入时间戳
} }
type ParsedColor = {
rgb: string
alpha: number
}
function parseColorToRgba(color?: string): ParsedColor | null {
if (!color) return null
const value = color.trim()
const rgbaMatch = value.match(/^rgba?\(([^)]+)\)$/i)
if (rgbaMatch) {
const parts = rgbaMatch[1].split(',').map(part => part.trim())
if (parts.length >= 3) {
const r = Number(parts[0])
const g = Number(parts[1])
const b = Number(parts[2])
const a = parts[3] !== undefined ? Number(parts[3]) : 1
return {
rgb: `${Number.isFinite(r) ? r : 0}, ${Number.isFinite(g) ? g : 0}, ${Number.isFinite(b) ? b : 0}`,
alpha: Number.isFinite(a) ? Math.max(0, Math.min(1, a)) : 1,
}
}
return null
}
if (value.startsWith('#')) {
const hex = value.slice(1)
const normalizeHex = (segment: string) => segment.length === 1 ? segment + segment : segment
let r = 0
let g = 0
let b = 0
let a = 255
if (hex.length === 3 || hex.length === 4) {
r = parseInt(normalizeHex(hex[0]), 16)
g = parseInt(normalizeHex(hex[1]), 16)
b = parseInt(normalizeHex(hex[2]), 16)
if (hex.length === 4) {
a = parseInt(normalizeHex(hex[3]), 16)
}
} else if (hex.length === 6 || hex.length === 8) {
r = parseInt(hex.slice(0, 2), 16)
g = parseInt(hex.slice(2, 4), 16)
b = parseInt(hex.slice(4, 6), 16)
if (hex.length === 8) {
a = parseInt(hex.slice(6, 8), 16)
}
} else {
return null
}
if ([r, g, b, a].some(value => Number.isNaN(value))) {
return null
}
return {
rgb: `${r}, ${g}, ${b}`,
alpha: Math.max(0, Math.min(1, a / 255)),
}
}
return null
}
let bc: BroadcastChannel | undefined let bc: BroadcastChannel | undefined
const setting = ref<DanmakuWindowSettings>() const setting = ref<DanmakuWindowSettings>()
const danmakuList = ref<TempDanmakuType[]>([]) const danmakuList = ref<TempDanmakuType[]>([])
@@ -33,7 +97,17 @@ function updateCssVariables() {
root.style.setProperty('--dw-direction', setting.value.reverseOrder ? 'column-reverse' : 'column') root.style.setProperty('--dw-direction', setting.value.reverseOrder ? 'column-reverse' : 'column')
// 背景和文字颜色 // 背景和文字颜色
root.style.setProperty('--dw-bg-color', setting.value.backgroundColor || 'rgba(0,0,0,0.6)') const bgColor = setting.value.backgroundColor || 'rgba(0,0,0,0.6)'
root.style.setProperty('--dw-bg-color', bgColor)
const parsedColor = parseColorToRgba(bgColor)
if (parsedColor) {
root.style.setProperty('--dw-bg-color-rgb', parsedColor.rgb)
root.style.setProperty('--dw-bg-alpha', `${parsedColor.alpha}`)
} else {
root.style.setProperty('--dw-bg-color-rgb', '0, 0, 0')
root.style.setProperty('--dw-bg-alpha', '0.6')
}
root.style.setProperty('--dw-window-bg-color', setting.value.windowBackgroundColor || 'transparent')
root.style.setProperty('--dw-text-color', setting.value.textColor || '#ffffff') root.style.setProperty('--dw-text-color', setting.value.textColor || '#ffffff')
// 尺寸相关 // 尺寸相关
@@ -224,6 +298,7 @@ watch(() => setting.value, () => {
class="danmaku-window" class="danmaku-window"
:class="{ 'has-items': hasItems, 'batch-update': isInBatchUpdate }" :class="{ 'has-items': hasItems, 'batch-update': isInBatchUpdate }"
> >
<div class="danmaku-window-bg" />
<div <div
ref="scrollContainerRef" ref="scrollContainerRef"
class="danmaku-list" class="danmaku-list"
@@ -260,6 +335,9 @@ html,
:root { :root {
--dw-bg-color: rgba(0, 0, 0, 0.6); --dw-bg-color: rgba(0, 0, 0, 0.6);
--dw-bg-color-rgb: 0, 0, 0;
--dw-bg-alpha: 0.6;
--dw-window-bg-color: transparent;
--dw-text-color: #ffffff; --dw-text-color: #ffffff;
--dw-border-radius: 0px; --dw-border-radius: 0px;
--dw-opacity: 1; --dw-opacity: 1;
@@ -272,12 +350,12 @@ html,
} }
.danmaku-window { .danmaku-window {
position: relative;
-webkit-app-region: drag; -webkit-app-region: drag;
overflow: hidden; overflow: hidden;
background-color: transparent;
/* 完全透明背景 */
width: 100%; width: 100%;
height: 100%; height: 100%;
border-radius: var(--dw-border-radius, 0);
color: var(--dw-text-color); color: var(--dw-text-color);
font-size: var(--dw-font-size); font-size: var(--dw-font-size);
box-shadow: var(--dw-shadow); box-shadow: var(--dw-shadow);
@@ -285,6 +363,15 @@ html,
transition: opacity 0.3s ease; transition: opacity 0.3s ease;
} }
.danmaku-window-bg {
position: absolute;
inset: 0;
border-radius: var(--dw-border-radius, 0);
background-color: var(--dw-window-bg-color, transparent);
backdrop-filter: blur(0);
pointer-events: none;
}
/* 没有弹幕时完全透明 */ /* 没有弹幕时完全透明 */
.danmaku-window:not(.has-items) { .danmaku-window:not(.has-items) {
opacity: 0; opacity: 0;
@@ -297,7 +384,6 @@ html,
display: flex; display: flex;
flex-direction: var(--dw-direction); flex-direction: var(--dw-direction);
box-sizing: border-box; box-sizing: border-box;
/* 确保padding不会增加元素的实际尺寸 */
} }
.danmaku-list-container { .danmaku-list-container {

View File

@@ -2,6 +2,7 @@
import { openUrl } from '@tauri-apps/plugin-opener' import { openUrl } from '@tauri-apps/plugin-opener'
import { useElementSize } from '@vueuse/core' import { useElementSize } from '@vueuse/core'
import { Live24Filled, CloudArchive24Filled, FlashAuto24Filled, Mic24Filled, Settings24Filled } from '@vicons/fluent' import { Live24Filled, CloudArchive24Filled, FlashAuto24Filled, Mic24Filled, Settings24Filled } from '@vicons/fluent'
import CookieInvalidAlert from './components/CookieInvalidAlert.vue'
import { cookie, useAccount } from '@/api/account' import { cookie, useAccount } from '@/api/account'
import { useWebFetcher } from '@/store/useWebFetcher' import { useWebFetcher } from '@/store/useWebFetcher'
import { roomInfo } from './data/info' import { roomInfo } from './data/info'
@@ -31,6 +32,10 @@ function logout() {
gap="large" gap="large"
wrap wrap
> >
<CookieInvalidAlert
class="client-index-alert"
variant="home"
/>
<NCard <NCard
title="首页" title="首页"
embedded embedded

View File

@@ -6,17 +6,18 @@ import { openUrl } from '@tauri-apps/plugin-opener'
import { Chat24Filled, CloudArchive24Filled, FlashAuto24Filled, Live24Filled, Mic24Filled, Settings24Filled } from '@vicons/fluent' import { Chat24Filled, CloudArchive24Filled, FlashAuto24Filled, Live24Filled, Mic24Filled, Settings24Filled } from '@vicons/fluent'
import { CheckmarkCircle, CloseCircle, Home } from '@vicons/ionicons5' import { CheckmarkCircle, CloseCircle, Home } from '@vicons/ionicons5'
import { NA, NButton, NCard, NInput, NLayout, NLayoutContent, NLayoutSider, NMenu, NSpace, NSpin, NText, NTooltip } from 'naive-ui' import { NA, NButton, NCard, NInput, NLayout, NLayoutContent, NLayoutSider, NMenu, NSpace, NSpin, NTag, NText, NTooltip } from 'naive-ui'
import { computed, h, ref } from 'vue' // 引入 ref, h, computed import { computed, h, ref } from 'vue' // 引入 ref, h, computed
import { RouterLink, RouterView } from 'vue-router' // 引入 Vue Router 组件 import { RouterLink, RouterView, useRouter } from 'vue-router' // 引入 Vue Router 组件
// 引入自定义 API 和状态管理 // 引入自定义 API 和状态管理
import { ACCOUNT, GetSelfAccount, isLoadingAccount, isLoggedIn } from '@/api/account' import { ACCOUNT, GetSelfAccount, isLoadingAccount, isLoggedIn } from '@/api/account'
import { useWebFetcher } from '@/store/useWebFetcher' import { useWebFetcher } from '@/store/useWebFetcher'
import { initAll, OnClientUnmounted, clientInited, clientInitStage } from './data/initialize' import { initAll, OnClientUnmounted, clientInited, clientInitStage } from './data/initialize'
import { useDanmakuWindow } from './store/useDanmakuWindow' import { useDanmakuWindow } from './store/useDanmakuWindow'
import { useBiliCookie } from './store/useBiliCookie'
// 引入子组件 // 引入子组件
import WindowBar from './WindowBar.vue' import WindowBar from './WindowBar.vue'
import { BASE_URL } from '@/data/constants' import { BASE_URL } from '@/data/constants'
@@ -24,11 +25,31 @@ import { BASE_URL } from '@/data/constants'
// --- 响应式状态 --- // --- 响应式状态 ---
// 获取 webfetcher 状态管理的实例 // 获取 webfetcher 状态管理的实例
const router = useRouter()
const webfetcher = useWebFetcher() const webfetcher = useWebFetcher()
const danmakuWindow = useDanmakuWindow() const danmakuWindow = useDanmakuWindow()
const biliCookie = useBiliCookie()
// 用于存储用户输入的 Token // 用于存储用户输入的 Token
const token = ref('') const token = ref('')
const cookieStatusType = computed(() => {
if (!biliCookie.hasBiliCookie) {
return 'warning'
}
return biliCookie.isCookieValid ? 'success' : 'error'
})
const cookieStatusText = computed(() => {
if (!biliCookie.hasBiliCookie) {
return '未同步'
}
return biliCookie.isCookieValid ? '正常' : '已失效'
})
function goCookieManagement() {
router.push({ name: 'client-fetcher' })
}
// --- 计算属性 --- // --- 计算属性 ---
// (这里没有显式的计算属性,但 isLoggedIn 本身可能是一个来自 account 模块的计算属性) // (这里没有显式的计算属性,但 isLoggedIn 本身可能是一个来自 account 模块的计算属性)
@@ -279,6 +300,33 @@ onMounted(() => {
default-value="go-back-home" default-value="go-back-home"
class="sider-menu" class="sider-menu"
/> />
<div class="cookie-status-card">
<div class="cookie-status-header">
<NText
strong
tag="div"
>
B站 Cookie
</NText>
<NTag
size="small"
:type="cookieStatusType"
:bordered="false"
>
{{ cookieStatusText }}
</NTag>
</div>
<NButton
v-if="cookieStatusType !== 'success'"
block
size="tiny"
type="primary"
class="cookie-status-button"
@click="goCookieManagement"
>
前往处理
</NButton>
</div>
</div> </div>
</NLayoutSider> </NLayoutSider>
@@ -452,6 +500,28 @@ onMounted(() => {
/* 菜单与顶部的间距 */ /* 菜单与顶部的间距 */
} }
.cookie-status-card {
margin-top: 12px;
padding: 12px;
border: 1px solid var(--n-border-color);
border-radius: 8px;
background-color: var(--n-card-color);
display: flex;
flex-direction: column;
gap: 8px;
}
.cookie-status-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.cookie-status-button {
margin-top: 4px;
}
/* Suspense 后备内容样式 */ /* Suspense 后备内容样式 */
.suspense-fallback { .suspense-fallback {
display: flex; display: flex;

View File

@@ -210,8 +210,19 @@ const separatorOptions = [
:x-gap="12" :x-gap="12"
> >
<NGi> <NGi>
<NFormItem label="背景颜色"> <NFormItem label="弹幕背景颜色">
<NColorPicker /> <NColorPicker
v-model:value="danmakuWindow.danmakuWindowSetting.backgroundColor"
:show-alpha="true"
/>
</NFormItem>
</NGi>
<NGi>
<NFormItem label="窗口背景颜色">
<NColorPicker
v-model:value="danmakuWindow.danmakuWindowSetting.windowBackgroundColor"
:show-alpha="true"
/>
</NFormItem> </NFormItem>
</NGi> </NGi>
<NGi> <NGi>
@@ -226,7 +237,7 @@ const separatorOptions = [
<NFormItem label="透明度"> <NFormItem label="透明度">
<NSlider <NSlider
v-model:value="danmakuWindow.danmakuWindowSetting.opacity" v-model:value="danmakuWindow.danmakuWindowSetting.opacity"
:min="0.1" :min="0"
:max="1" :max="1"
:step="0.05" :step="0.05"
/> />

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { NAlert, NButton } from 'naive-ui'
import { useBiliCookie } from '../store/useBiliCookie'
const biliCookie = useBiliCookie()
const router = useRouter()
const props = withDefaults(defineProps<{
variant?: 'home' | 'fetcher'
}>(), {
variant: 'home',
})
const goToFetcher = () => {
router.push({ name: 'client-fetcher' })
}
const goToSettings = () => {
router.push({ name: 'client-settings' })
}
</script>
<template>
<NAlert
v-if="biliCookie.hasBiliCookie && !biliCookie.isCookieValid"
class="cookie-invalid-alert"
type="error"
:title="props.variant === 'home' ? '需重新登录 B 站账号' : 'EventFetcher 需要有效的 B 站 Cookie'"
:show-icon="true"
:bordered="false"
>
<div class="cookie-invalid-alert__content">
<p>
{{ props.variant === 'home'
? '检测到 B 站 Cookie 已失效,客户端功能将受限。'
: '请尽快同步或重新登录 Cookie以保证事件采集稳定运行。'
}}
</p>
<p>
如果已经部署 CookieCloud请尝试重新同步否则请重新扫码登录
</p>
<div class="cookie-invalid-alert__actions">
<NButton
size="small"
type="primary"
@click="goToFetcher"
>
前往 EventFetcher
</NButton>
<NButton
size="small"
tertiary
@click="goToSettings"
>
Cookie 设置
</NButton>
</div>
</div>
</NAlert>
</template>
<style scoped>
.cookie-invalid-alert {
width: 100%;
box-sizing: border-box;
}
.cookie-invalid-alert__content {
font-size: 13px;
line-height: 1.6;
color: rgb(51 54 57 / 90%);
}
.cookie-invalid-alert__actions {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
</style>

View File

@@ -36,6 +36,50 @@ let heartbeatTimer: number | null = null
let updateCheckTimer: number | null = null let updateCheckTimer: number | null = null
let updateNotificationRef: any = null let updateNotificationRef: any = null
function setInitStageSafely(stage: string) {
if (clientInitStage.value !== '启动完成') {
clientInitStage.value = stage
}
}
function startDanmakuClientInitFlow() {
const danmakuInitNoticeRef = window.$notification.info({
title: '正在初始化弹幕客户端...',
closable: false,
})
setInitStageSafely('初始化弹幕客户端...')
void initDanmakuClient()
.then((result) => {
if (result.success) {
info('[init] 弹幕客户端初始化完成')
window.$notification.success({
title: '弹幕客户端初始化完成',
duration: 3000,
})
setInitStageSafely('弹幕客户端初始化完成')
} else {
warn(`[init] 弹幕客户端初始化失败: ${result.message}`)
window.$notification.error({
title: '弹幕客户端初始化失败',
content: result.message || '请稍后重试',
})
setInitStageSafely('弹幕客户端初始化失败')
}
})
.catch((error) => {
warn(`[init] 弹幕客户端初始化异常: ${error}`)
window.$notification.error({
title: '弹幕客户端初始化异常',
content: `${error}`,
})
setInitStageSafely('弹幕客户端初始化失败')
})
.finally(() => {
danmakuInitNoticeRef?.destroy()
})
}
// interface RtmpRelayState { // interface RtmpRelayState {
// roomId: number // roomId: number
// targetRtmpUrl: string // targetRtmpUrl: string
@@ -334,29 +378,6 @@ export async function initAll(isOnBoot: boolean) {
initInfo() initInfo()
info('[init] 开始更新数据') info('[init] 开始更新数据')
if (isLoggedIn.value && accountInfo.value.isBiliVerified && !setting.settings.dev_disableDanmakuClient) {
const danmakuInitNoticeRef = window.$notification.info({
title: '正在初始化弹幕客户端...',
closable: false,
})
clientInitStage.value = '初始化弹幕客户端...'
const result = await initDanmakuClient()
danmakuInitNoticeRef.destroy()
if (result.success) {
window.$notification.success({
title: '弹幕客户端初始化完成',
duration: 3000,
})
clientInitStage.value = '弹幕客户端初始化完成'
} else {
window.$notification.error({
title: `弹幕客户端初始化失败: ${result.message}`,
})
clientInitStage.value = '弹幕客户端初始化失败'
}
}
info('[init] 已加载弹幕客户端')
// 初始化系统托盘图标和菜单
clientInitStage.value = '创建系统托盘...' clientInitStage.value = '创建系统托盘...'
const menu = await Menu.new({ const menu = await Menu.new({
items: [ items: [
@@ -378,7 +399,6 @@ export async function initAll(isOnBoot: boolean) {
}) })
const iconData = await (await fetch('https://oss.suki.club/vtsuru/icon.ico')).arrayBuffer() const iconData = await (await fetch('https://oss.suki.club/vtsuru/icon.ico')).arrayBuffer()
const options: TrayIconOptions = { const options: TrayIconOptions = {
// here you can add a tray menu, title, tooltip, event handler, etc
menu, menu,
title: 'VTsuru.Client', title: 'VTsuru.Client',
tooltip: 'VTsuru 事件收集器', tooltip: 'VTsuru 事件收集器',
@@ -393,6 +413,15 @@ export async function initAll(isOnBoot: boolean) {
tray = await TrayIcon.new(options) tray = await TrayIcon.new(options)
clientInitStage.value = '系统托盘就绪' clientInitStage.value = '系统托盘就绪'
const shouldInitDanmakuClient = isLoggedIn.value
&& accountInfo.value.isBiliVerified
&& !setting.settings.dev_disableDanmakuClient
if (shouldInitDanmakuClient) {
startDanmakuClientInitFlow()
} else {
info('[init] 跳过弹幕客户端初始化')
}
appWindow.setMinSize(new PhysicalSize(720, 480)) appWindow.setMinSize(new PhysicalSize(720, 480))
getAllWebviewWindows().then(async (windows) => { getAllWebviewWindows().then(async (windows) => {

View File

@@ -24,6 +24,7 @@ export interface DanmakuWindowSettings {
animationDuration: number // 动画持续时间 animationDuration: number // 动画持续时间
enableAnimation: boolean // 是否启用动画效果 enableAnimation: boolean // 是否启用动画效果
backgroundColor: string // 背景色 backgroundColor: string // 背景色
windowBackgroundColor: string // 窗口背景色
textColor: string // 文字颜色 textColor: string // 文字颜色
alwaysOnTop: boolean // 是否总在最前 alwaysOnTop: boolean // 是否总在最前
interactive: boolean // 是否可交互(穿透鼠标点击) interactive: boolean // 是否可交互(穿透鼠标点击)
@@ -173,6 +174,7 @@ export const useDanmakuWindow = defineStore('danmakuWindow', () => {
filterTypes: ['Message', 'Gift', 'SC', 'Guard'], filterTypes: ['Message', 'Gift', 'SC', 'Guard'],
animationDuration: 300, animationDuration: 300,
backgroundColor: 'rgba(0,0,0,0.6)', backgroundColor: 'rgba(0,0,0,0.6)',
windowBackgroundColor: 'rgba(0,0,0,0)',
textColor: '#ffffff', textColor: '#ffffff',
alwaysOnTop: true, alwaysOnTop: true,
interactive: false, interactive: false,