mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-06 18:36:55 +08:00
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:
@@ -53,7 +53,7 @@
|
||||
"md5": "^2.3.0",
|
||||
"mitt": "^3.0.1",
|
||||
"monaco-editor": "^0.54.0",
|
||||
"naive-ui": "2.43.2",
|
||||
"naive-ui": "^2.43.2",
|
||||
"nanoid": "^5.1.6",
|
||||
"obs-websocket-js": "^5.0.7",
|
||||
"peerjs": "^1.5.5",
|
||||
|
||||
@@ -15,6 +15,70 @@ type TempDanmakuType = EventModel & {
|
||||
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
|
||||
const setting = ref<DanmakuWindowSettings>()
|
||||
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-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')
|
||||
|
||||
// 尺寸相关
|
||||
@@ -224,6 +298,7 @@ watch(() => setting.value, () => {
|
||||
class="danmaku-window"
|
||||
:class="{ 'has-items': hasItems, 'batch-update': isInBatchUpdate }"
|
||||
>
|
||||
<div class="danmaku-window-bg" />
|
||||
<div
|
||||
ref="scrollContainerRef"
|
||||
class="danmaku-list"
|
||||
@@ -260,6 +335,9 @@ html,
|
||||
|
||||
:root {
|
||||
--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-border-radius: 0px;
|
||||
--dw-opacity: 1;
|
||||
@@ -272,12 +350,12 @@ html,
|
||||
}
|
||||
|
||||
.danmaku-window {
|
||||
position: relative;
|
||||
-webkit-app-region: drag;
|
||||
overflow: hidden;
|
||||
background-color: transparent;
|
||||
/* 完全透明背景 */
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: var(--dw-border-radius, 0);
|
||||
color: var(--dw-text-color);
|
||||
font-size: var(--dw-font-size);
|
||||
box-shadow: var(--dw-shadow);
|
||||
@@ -285,6 +363,15 @@ html,
|
||||
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) {
|
||||
opacity: 0;
|
||||
@@ -297,7 +384,6 @@ html,
|
||||
display: flex;
|
||||
flex-direction: var(--dw-direction);
|
||||
box-sizing: border-box;
|
||||
/* 确保padding不会增加元素的实际尺寸 */
|
||||
}
|
||||
|
||||
.danmaku-list-container {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { Live24Filled, CloudArchive24Filled, FlashAuto24Filled, Mic24Filled, Settings24Filled } from '@vicons/fluent'
|
||||
import CookieInvalidAlert from './components/CookieInvalidAlert.vue'
|
||||
import { cookie, useAccount } from '@/api/account'
|
||||
import { useWebFetcher } from '@/store/useWebFetcher'
|
||||
import { roomInfo } from './data/info'
|
||||
@@ -31,6 +32,10 @@ function logout() {
|
||||
gap="large"
|
||||
wrap
|
||||
>
|
||||
<CookieInvalidAlert
|
||||
class="client-index-alert"
|
||||
variant="home"
|
||||
/>
|
||||
<NCard
|
||||
title="首页"
|
||||
embedded
|
||||
|
||||
@@ -6,17 +6,18 @@ import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
|
||||
import { Chat24Filled, CloudArchive24Filled, FlashAuto24Filled, Live24Filled, Mic24Filled, Settings24Filled } from '@vicons/fluent'
|
||||
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 { RouterLink, RouterView } from 'vue-router' // 引入 Vue Router 组件
|
||||
import { RouterLink, RouterView, useRouter } from 'vue-router' // 引入 Vue Router 组件
|
||||
// 引入自定义 API 和状态管理
|
||||
import { ACCOUNT, GetSelfAccount, isLoadingAccount, isLoggedIn } from '@/api/account'
|
||||
|
||||
import { useWebFetcher } from '@/store/useWebFetcher'
|
||||
import { initAll, OnClientUnmounted, clientInited, clientInitStage } from './data/initialize'
|
||||
import { useDanmakuWindow } from './store/useDanmakuWindow'
|
||||
import { useBiliCookie } from './store/useBiliCookie'
|
||||
// 引入子组件
|
||||
import WindowBar from './WindowBar.vue'
|
||||
import { BASE_URL } from '@/data/constants'
|
||||
@@ -24,11 +25,31 @@ import { BASE_URL } from '@/data/constants'
|
||||
// --- 响应式状态 ---
|
||||
|
||||
// 获取 webfetcher 状态管理的实例
|
||||
const router = useRouter()
|
||||
const webfetcher = useWebFetcher()
|
||||
const danmakuWindow = useDanmakuWindow()
|
||||
const biliCookie = useBiliCookie()
|
||||
// 用于存储用户输入的 Token
|
||||
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 模块的计算属性)
|
||||
|
||||
@@ -279,6 +300,33 @@ onMounted(() => {
|
||||
default-value="go-back-home"
|
||||
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>
|
||||
</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-fallback {
|
||||
display: flex;
|
||||
|
||||
@@ -210,8 +210,19 @@ const separatorOptions = [
|
||||
:x-gap="12"
|
||||
>
|
||||
<NGi>
|
||||
<NFormItem label="背景颜色">
|
||||
<NColorPicker />
|
||||
<NFormItem label="弹幕背景颜色">
|
||||
<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>
|
||||
</NGi>
|
||||
<NGi>
|
||||
@@ -226,7 +237,7 @@ const separatorOptions = [
|
||||
<NFormItem label="透明度">
|
||||
<NSlider
|
||||
v-model:value="danmakuWindow.danmakuWindowSetting.opacity"
|
||||
:min="0.1"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.05"
|
||||
/>
|
||||
|
||||
82
src/client/components/CookieInvalidAlert.vue
Normal file
82
src/client/components/CookieInvalidAlert.vue
Normal 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>
|
||||
@@ -36,6 +36,50 @@ let heartbeatTimer: number | null = null
|
||||
let updateCheckTimer: number | null = 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 {
|
||||
// roomId: number
|
||||
// targetRtmpUrl: string
|
||||
@@ -334,29 +378,6 @@ export async function initAll(isOnBoot: boolean) {
|
||||
initInfo()
|
||||
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 = '创建系统托盘...'
|
||||
const menu = await Menu.new({
|
||||
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 options: TrayIconOptions = {
|
||||
// here you can add a tray menu, title, tooltip, event handler, etc
|
||||
menu,
|
||||
title: 'VTsuru.Client',
|
||||
tooltip: 'VTsuru 事件收集器',
|
||||
@@ -393,6 +413,15 @@ export async function initAll(isOnBoot: boolean) {
|
||||
tray = await TrayIcon.new(options)
|
||||
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))
|
||||
|
||||
getAllWebviewWindows().then(async (windows) => {
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface DanmakuWindowSettings {
|
||||
animationDuration: number // 动画持续时间
|
||||
enableAnimation: boolean // 是否启用动画效果
|
||||
backgroundColor: string // 背景色
|
||||
windowBackgroundColor: string // 窗口背景色
|
||||
textColor: string // 文字颜色
|
||||
alwaysOnTop: boolean // 是否总在最前
|
||||
interactive: boolean // 是否可交互(穿透鼠标点击)
|
||||
@@ -173,6 +174,7 @@ export const useDanmakuWindow = defineStore('danmakuWindow', () => {
|
||||
filterTypes: ['Message', 'Gift', 'SC', 'Guard'],
|
||||
animationDuration: 300,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
windowBackgroundColor: 'rgba(0,0,0,0)',
|
||||
textColor: '#ffffff',
|
||||
alwaysOnTop: true,
|
||||
interactive: false,
|
||||
|
||||
Reference in New Issue
Block a user