feat: 更新项目配置和依赖,增强功能和用户体验

- 完成弹幕机功能
- 在 .editorconfig 中新增对 vine.ts 文件的支持。
- 更新 package.json 中多个依赖的版本,提升稳定性和性能。
- 在 vite.config.mts 中引入 @guolao/vue-monaco-editor 插件,增强代码编辑功能。
- 在 App.vue 中调整内容填充的样式,优化界面布局。
- 新增获取配置文件哈希的 API 方法,提升配置管理能力。
- 在多个组件中优化了样式和逻辑,提升用户交互体验。
This commit is contained in:
2025-04-25 00:08:06 +08:00
parent b24974540f
commit 07948e6777
36 changed files with 3108 additions and 1258 deletions

View File

@@ -243,7 +243,7 @@ onMounted(async () => {
style="width: 100%"
>
<NAlert type="success">
你已完成验证! 请妥善保存你的登陆链接, 请勿让其他人获取. 丢失后可以再次通过认证流程获得
你已完成验证! 请妥善保存你的登陆链接, 请勿让其他人获取. 丢失后可以再次通过认证流程获得. 把这个链接复制到浏览器打开即可登录
</NAlert>
<NText> 你的登陆链接为: </NText>
<NInputGroup>

View File

@@ -84,246 +84,255 @@ watch(aplayer, () => {
// 邮箱验证相关
const canResendEmail = ref(false)
const isBiliVerified = computed(() => accountInfo.value?.isBiliVerified)
// 图标渲染函数 - 用于菜单项
const renderIcon = (icon: any) => () => h(NIcon, null, { default: () => h(icon) })
// 菜单配置
const menuOptions = [
{
label: () => h(RouterLink, { to: { name: 'manage-history' } }, { default: () => '历史' }),
key: 'manage-history',
disabled: accountInfo.value?.isEmailVerified === false,
icon: renderIcon(AnalyticsSharp),
},
{
label: () => h(RouterLink, { to: { name: 'manage-live' } }, { default: () => '直播记录' }),
key: 'manage-live',
disabled: accountInfo.value?.isEmailVerified === false,
icon: renderIcon(Live24Filled),
},
{
label: () => h(RouterLink, { to: { name: 'manage-analyze' } }, { default: () => '直播数据' }),
key: 'manage-analyze',
disabled: accountInfo.value?.isEmailVerified === false,
icon: renderIcon(Eye),
},
{
label: () => h(RouterLink, { to: { name: 'manage-event' } }, { default: () => '舰长和SC' }),
key: 'manage-event',
disabled: accountInfo.value?.isEmailVerified === false,
icon: renderIcon(VehicleShip24Filled),
},
{
label: () => h(RouterLink, { to: { name: 'manage-point' } }, { default: () => '积分和礼物' }),
key: 'manage-point',
disabled: accountInfo.value?.isEmailVerified === false,
icon: renderIcon(BookCoins20Filled),
},
{
label: () => h(RouterLink, { to: { name: 'manage-schedule' } }, { default: () => '日程' }),
key: 'manage-schedule',
icon: renderIcon(CalendarClock24Filled),
disabled: accountInfo.value?.isEmailVerified === false,
},
{
label: () => h(RouterLink, { to: { name: 'manage-songList' } }, { default: () => '歌单' }),
key: 'manage-songList',
icon: renderIcon(MusicalNote),
disabled: accountInfo.value?.isEmailVerified === false,
},
{
label: () => h(RouterLink, { to: { name: 'manage-questionBox' } }, { default: () => '棉花糖 (提问箱' }),
key: 'manage-questionBox',
icon: renderIcon(Chatbox),
disabled: accountInfo.value?.isEmailVerified === false,
},
{
label: () => h(RouterLink, { to: { name: 'manage-videoCollect' } }, { default: () => '视频征集' }),
key: 'manage-videoCollect',
icon: renderIcon(VideoAdd20Filled),
disabled: accountInfo.value?.isEmailVerified === false,
},
{
label: () => h(RouterLink, { to: { name: 'manage-lottery' } }, { default: () => '动态抽奖' }),
key: 'manage-lottery',
icon: renderIcon(Lottery24Filled),
},
{
label: () => h(
NTooltip,
{},
{
trigger: () => h(
NText,
() => [
'弹幕相关',
h(
const menuOptions = computed(() => {
return [
{
label: () => h(RouterLink, { to: { name: 'manage-history' } }, { default: () => '历史' }),
key: 'manage-history',
disabled: accountInfo.value?.isEmailVerified === false,
icon: renderIcon(AnalyticsSharp),
},
{
label: () => h(RouterLink, { to: { name: 'manage-live' } }, { default: () => '直播记录' }),
key: 'manage-live',
disabled: accountInfo.value?.isEmailVerified === false,
icon: renderIcon(Live24Filled),
},
{
label: () => h(RouterLink, { to: { name: 'manage-analyze' } }, { default: () => '直播数据' }),
key: 'manage-analyze',
disabled: accountInfo.value?.isEmailVerified === false,
icon: renderIcon(Eye),
},
{
label: () => h(RouterLink, { to: { name: 'manage-event' } }, { default: () => '舰长和SC' }),
key: 'manage-event',
disabled: accountInfo.value?.isEmailVerified === false,
icon: renderIcon(VehicleShip24Filled),
},
{
label: () => h(RouterLink, { to: { name: 'manage-point' } }, { default: () => '积分和礼物' }),
key: 'manage-point',
disabled: accountInfo.value?.isEmailVerified === false,
icon: renderIcon(BookCoins20Filled),
},
{
label: () => h(RouterLink, { to: { name: 'manage-schedule' } }, { default: () => '日程' }),
key: 'manage-schedule',
icon: renderIcon(CalendarClock24Filled),
disabled: accountInfo.value?.isEmailVerified === false,
},
{
label: () => h(RouterLink, { to: { name: 'manage-songList' } }, { default: () => '歌单' }),
key: 'manage-songList',
icon: renderIcon(MusicalNote),
disabled: accountInfo.value?.isEmailVerified === false,
},
{
label: () => h(RouterLink, { to: { name: 'manage-questionBox' } }, { default: () => '棉花糖 (提问箱' }),
key: 'manage-questionBox',
icon: renderIcon(Chatbox),
disabled: accountInfo.value?.isEmailVerified === false,
},
{
label: () => h(RouterLink, { to: { name: 'manage-videoCollect' } }, { default: () => '视频征集' }),
key: 'manage-videoCollect',
icon: renderIcon(VideoAdd20Filled),
disabled: accountInfo.value?.isEmailVerified === false,
},
{
label: () => h(RouterLink, { to: { name: 'manage-lottery' } }, { default: () => '动态抽奖' }),
key: 'manage-lottery',
icon: renderIcon(Lottery24Filled),
},
{
label: () => h(
NTooltip,
{},
{
trigger: () => h(
NText,
() => [
'弹幕相关',
h(
NTooltip,
{ style: 'padding: 0;' },
{
trigger: () => h(NIcon, { component: Info24Filled }),
default: () => h(
NAlert,
{
type: 'warning',
size: 'small',
title: '可用性警告',
style: 'max-width: 600px;',
},
() => h('div', {}, [
' 当浏览器在后台运行时, 定时器和 Websocket 连接将受到严格限制, 这会导致弹幕接收功能无法正常工作 (详见',
h(
NButton,
{
text: true,
tag: 'a',
href: 'https://developer.chrome.com/blog/background_tabs/',
target: '_blank',
type: 'info',
},
() => '此文章',
),
'), 虽然本站已经针对此问题做出了处理, 一般情况下即使掉线了也会重连, 不过还是有可能会遗漏事件',
h('br'),
'为避免这种情况, 建议注册本站账后使用',
h(
NButton,
{
type: 'primary',
text: true,
size: 'small',
tag: 'a',
href: 'https://www.wolai.com/fje5wLtcrDoZcb9rk2zrFs',
target: '_blank',
},
() => 'VtsuruEventFetcher',
),
', 否则请在使用功能时尽量保持网页在前台运行, 同时关闭浏览器的 页面休眠/内存节省 功能',
h('br'),
'Chrome: ',
h(
NButton,
{
type: 'info',
text: true,
size: 'small',
tag: 'a',
href: 'https://support.google.com/chrome/answer/12929150?hl=zh-Hans#zippy=%2C%E5%BC%80%E5%90%AF%E6%88%96%E5%85%B3%E9%97%AD%E7%9C%81%E5%86%85%E5%AD%98%E6%A8%A1%E5%BC%8F%2C%E8%AE%A9%E7%89%B9%E5%AE%9A%E7%BD%91%E7%AB%99%E4%BF%9D%E6%8C%81%E6%B4%BB%E5%8A%A8%E7%8A%B6%E6%80%81',
target: '_blank',
},
() => '让特定网站保持活动状态',
),
', Edge: ',
h(
NButton,
{
type: 'info',
text: true,
size: 'small',
tag: 'a',
href: 'https://support.microsoft.com/zh-cn/topic/%E4%BA%86%E8%A7%A3-microsoft-edge-%E4%B8%AD%E7%9A%84%E6%80%A7%E8%83%BD%E5%8A%9F%E8%83%BD-7b36f363-2119-448a-8de6-375cfd88ab25',
target: '_blank',
},
() => '永远不想进入睡眠状态的网站',
),
]),
),
},
),
]
),
default: () => isBiliVerified.value
? '需要使用直播弹幕的功能'
: '你尚未进行 Bilibili 认证, 请前往面板进行绑定',
},
),
key: 'manage-danmaku',
icon: renderIcon(Chat24Filled),
disabled: accountInfo.value?.isEmailVerified === false || !isBiliVerified.value,
children: [
{
label: () => !isBiliVerified.value ? '弹幕机' : h(
NBadge,
{ value: '新', offset: [15, 12], type: 'info' },
() => h(
NTooltip,
{ style: 'padding: 0;' },
{},
{
trigger: () => h(NIcon, { component: Info24Filled }),
default: () => h(
NAlert,
{
type: 'warning',
size: 'small',
title: '可用性警告',
style: 'max-width: 600px;',
},
() => h('div', {}, [
' 当浏览器在后台运行时, 定时器和 Websocket 连接将受到严格限制, 这会导致弹幕接收功能无法正常工作 (详见',
h(
NButton,
{
text: true,
tag: 'a',
href: 'https://developer.chrome.com/blog/background_tabs/',
target: '_blank',
type: 'info',
},
() => '此文章',
),
'), 虽然本站已经针对此问题做出了处理, 一般情况下即使掉线了也会重连, 不过还是有可能会遗漏事件',
h('br'),
'为避免这种情况, 建议注册本站账后使用',
h(
NButton,
{
type: 'primary',
text: true,
size: 'small',
tag: 'a',
href: 'https://www.wolai.com/fje5wLtcrDoZcb9rk2zrFs',
target: '_blank',
},
() => 'VtsuruEventFetcher',
),
', 否则请在使用功能时尽量保持网页在前台运行, 同时关闭浏览器的 页面休眠/内存节省 功能',
h('br'),
'Chrome: ',
h(
NButton,
{
type: 'info',
text: true,
size: 'small',
tag: 'a',
href: 'https://support.google.com/chrome/answer/12929150?hl=zh-Hans#zippy=%2C%E5%BC%80%E5%90%AF%E6%88%96%E5%85%B3%E9%97%AD%E7%9C%81%E5%86%85%E5%AD%98%E6%A8%A1%E5%BC%8F%2C%E8%AE%A9%E7%89%B9%E5%AE%9A%E7%BD%91%E7%AB%99%E4%BF%9D%E6%8C%81%E6%B4%BB%E5%8A%A8%E7%8A%B6%E6%80%81',
target: '_blank',
},
() => '让特定网站保持活动状态',
),
', Edge: ',
h(
NButton,
{
type: 'info',
text: true,
size: 'small',
tag: 'a',
href: 'https://support.microsoft.com/zh-cn/topic/%E4%BA%86%E8%A7%A3-microsoft-edge-%E4%B8%AD%E7%9A%84%E6%80%A7%E8%83%BD%E5%8A%9F%E8%83%BD-7b36f363-2119-448a-8de6-375cfd88ab25',
target: '_blank',
},
() => '永远不想进入睡眠状态的网站',
),
]),
trigger: () => h(
RouterLink,
{ to: { name: 'manage-danmuji' } },
{ default: () => '弹幕机' },
),
},
),
]
),
default: () => accountInfo.value?.isBiliVerified
? '需要使用直播弹幕的功能'
: '你尚未进行 Bilibili 认证, 请前往面板进行绑定',
},
),
key: 'manage-danmaku',
icon: renderIcon(Chat24Filled),
disabled: accountInfo.value?.isEmailVerified === false,
children: [
{
label: () => h(
NBadge,
{ value: '新', offset: [15, 12], type: 'info' },
() => h(
default: () => '兼容 blivechat 样式 (其实就是直接用的 blivechat 组件',
}
)
),
key: 'manage-danmuji',
disabled: !isBiliVerified.value,
icon: renderIcon(Lottery24Filled),
},
{
label: () => !isBiliVerified.value ? '抽奖' : h(
RouterLink,
{ to: { name: 'manage-liveLottery' } },
{ default: () => '抽奖' },
),
key: 'manage-liveLottery',
icon: renderIcon(Lottery24Filled),
disabled: !isBiliVerified.value,
},
{
label: () => !isBiliVerified.value ? '点播' : h(
NTooltip,
{},
{
trigger: () => h(
RouterLink,
{ to: { name: 'manage-danmuji' } },
{ default: () => '弹幕机' },
{ to: { name: 'manage-liveRequest' } },
{ default: () => '点播' },
),
default: () => '兼容 blivechat 样式 (其实就是直接用的 blivechat 组件',
}
)
),
key: 'manage-danmuji',
icon: renderIcon(Lottery24Filled),
},
{
label: () => h(
RouterLink,
{ to: { name: 'manage-liveLottery' } },
{ default: () => '抽奖' },
),
key: 'manage-liveLottery',
icon: renderIcon(Lottery24Filled),
},
{
label: () => h(
NTooltip,
{},
{
trigger: () => h(
RouterLink,
{ to: { name: 'manage-liveRequest' } },
{ default: () => '点播' },
),
default: () => '歌势之类用的, 可以用来点歌或者跳舞什么的',
},
),
key: 'manage-liveRequest',
icon: renderIcon(MusicalNote),
},
{
label: () => h(
NTooltip,
{},
{
trigger: () => h(
RouterLink,
{ to: { name: 'manage-musicRequest' } },
{ default: () => '点歌' },
),
default: () => '就是传统的点歌机, 发弹幕后播放指定的歌曲',
},
),
key: 'manage-musicRequest',
icon: renderIcon(MusicalNote),
},
{
label: () => h(
RouterLink,
{ to: { name: 'manage-liveQueue' } },
{ default: () => '排队' },
),
key: 'manage-liveQueue',
icon: renderIcon(PeopleQueue24Filled),
},
{
label: () => h(
RouterLink,
{ to: { name: 'manage-speech' } },
{ default: () => '读弹幕' },
),
key: 'manage-speech',
icon: renderIcon(TabletSpeaker24Filled),
},
],
},
]
default: () => '歌势之类用的, 可以用来点歌或者跳舞什么的',
},
),
key: 'manage-liveRequest',
icon: renderIcon(MusicalNote),
disabled: !isBiliVerified.value,
},
{
label: () => !isBiliVerified.value ? '点歌' : h(
NTooltip,
{},
{
trigger: () => h(
RouterLink,
{ to: { name: 'manage-musicRequest' } },
{ default: () => '点歌' },
),
default: () => '就是传统的点歌机, 发弹幕后播放指定的歌曲',
},
),
key: 'manage-musicRequest',
icon: renderIcon(MusicalNote),
disabled: !isBiliVerified.value,
},
{
label: () => !isBiliVerified.value ? '排队' : h(
RouterLink,
{ to: { name: 'manage-liveQueue' } },
{ default: () => '排队' },
),
key: 'manage-liveQueue',
icon: renderIcon(PeopleQueue24Filled),
disabled: !isBiliVerified.value,
},
{
label: () => !isBiliVerified.value ? '读弹幕' : h(
RouterLink,
{ to: { name: 'manage-speech' } },
{ default: () => '读弹幕' },
),
key: 'manage-speech',
icon: renderIcon(TabletSpeaker24Filled),
disabled: !isBiliVerified.value,
},
],
},
]
})
// 重发验证邮件
async function resendEmail() {
@@ -385,7 +394,7 @@ onMounted(() => {
<!-- 顶部导航栏 -->
<NLayoutHeader
bordered
style="height: 50px; padding: 10px 15px 5px 15px"
style="height: var(--vtsuru-header-height); padding: 10px 15px 5px 15px"
>
<NPageHeader>
<template #title>
@@ -432,6 +441,7 @@ onMounted(() => {
>
<!-- 侧边导航栏 -->
<NLayoutSider
v-if="accountInfo?.isEmailVerified"
ref="sider"
bordered
show-trigger
@@ -541,8 +551,8 @@ onMounted(() => {
<!-- 内容区域 -->
<NLayout>
<!-- 主内容区域 -->
<NScrollbar :style="`height: calc(100vh - 50px - ${aplayerHeight}px)`">
<NLayoutContent content-style="margin: 12px; margin-right: 16px">
<NScrollbar :style="`height: calc(100vh - var(--vtsuru-header-height) - ${aplayerHeight}px)`">
<NLayoutContent content-style="margin: var(--vtsuru-content-padding); margin-right: calc(var(--vtsuru-content-padding) + 4px)">
<NElement>
<!-- 已验证邮箱的用户显示内容 -->
<RouterView
@@ -706,12 +716,13 @@ onMounted(() => {
position: fixed;
top: 0;
left: 0;
overflow: auto;
"
:class="isDarkMode ? 'login-dark-bg' : ''"
>
<template v-if="!isLoadingAccount">
<NCard
style="max-width: 520px; width: 100%; min-width: 350px; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.08); margin: 16px;"
class="login-card"
:bordered="false"
>
<template #header>
@@ -753,7 +764,7 @@ onMounted(() => {
size="small"
>
<div style="text-align: center;">
如果你不是主播且不发送棉花糖(提问)的话则不需要注册登录
如果你不是主播且不发送棉花糖(提问)的话则不需要注册登录, 直接访问认证完成后给出的链接即可
</div>
<NFlex
justify="center"
@@ -762,7 +773,7 @@ onMounted(() => {
<NButton
type="primary"
size="small"
@click="$router.push({ name: 'bili-user'})"
@click="$router.push({ name: 'bili-user' })"
>
<template #icon>
<NIcon :component="BrowsersOutline" />
@@ -792,8 +803,8 @@ onMounted(() => {
</template>
<template v-else>
<NCard
class="loading-card"
:bordered="false"
style="min-width: 300px; width: 100%; max-width: 400px; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.08); margin: 16px;"
>
<NFlex
vertical
@@ -816,6 +827,31 @@ onMounted(() => {
<style scoped>
.login-dark-bg {
background: linear-gradient(135deg, rgba(30,30,35,0.9) 0%, rgba(20,20,25,0.95) 100%) !important;
background: linear-gradient(135deg, rgba(30, 30, 35, 0.9) 0%, rgba(20, 20, 25, 0.95) 100%) !important;
}
.login-card {
max-width: 520px;
width: 90%;
min-width: 300px;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0,0,0,0.08);
margin: 16px;
}
.loading-card {
min-width: 280px;
width: 90%;
max-width: 400px;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0,0,0,0.08);
margin: 16px;
}
@media (max-width: 480px) {
.login-card, .loading-card {
width: 95%;
margin: 8px;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,20 @@
<script setup lang="ts">
import { useDanmakuClient } from '@/store/useDanmakuClient';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import MessageRender from './blivechat/MessageRender.vue';
// @ts-ignore
import * as constants from './blivechat/constants';
// @ts-ignore
import * as pronunciation from './blivechat/utils/pronunciation';
import { DownloadConfig, useAccount } from '@/api/account';
import { useAccount, DownloadConfig, GetConfigHash } from '@/api/account';
import { QueryGetAPI } from '@/api/query';
import { VTSURU_API_URL } from '@/data/constants';
import { DanmakuInfo, GiftInfo, GuardInfo, SCInfo } from '@/data/DanmakuClients/OpenLiveClient';
import { defaultDanmujiCss, VTSURU_API_URL } from '@/data/constants';
import { useWebRTC } from '@/store/useRTC';
import { NAlert } from 'naive-ui';
import { useRoute } from 'vue-router';
// @ts-ignore
import * as pronunciation from './blivechat/utils/pronunciation';
import { EventDataTypes, EventModel } from '@/api/api-models';
// @ts-ignore
import * as trie from './blivechat/utils/trie';
export interface DanmujiConfig {
@@ -39,20 +40,6 @@ export interface DanmujiConfig {
}[]
}
defineExpose({ setCss })
const { customCss, isOBS = true } = defineProps<{
customCss?: string
isOBS?: boolean,
active?: boolean,
visible?: boolean,
}>()
const messageRender = ref()
const client = await useDanmakuClient().initOpenlive()
const pronunciationConverter = new pronunciation.PronunciationConverter()
const accountInfo = useAccount()
const route = useRoute()
// 默认配置
const defaultConfig: DanmujiConfig = {
minGiftPrice: 0.1,
@@ -74,9 +61,29 @@ const defaultConfig: DanmujiConfig = {
emoticons: []
}
defineExpose({ setCss, testAddMessage })
const props = defineProps<{
active?: boolean,
visible?: boolean,
config?: DanmujiConfig
}>()
const customCss = useStorage('danmuji-css', '');
const isOBS = computed(() => {
// @ts-ignore
return window.obsstudio !== undefined
})
const messageRender = ref()
const client = await useDanmakuClient().initOpenlive()
const pronunciationConverter = new pronunciation.PronunciationConverter()
const accountInfo = useAccount()
const route = useRoute()
const config = computed(() => props.config ?? defaultConfig)
let textEmoticons: { keyword: string, url: string }[] = []
const config = ref<DanmujiConfig>(JSON.parse(JSON.stringify(defaultConfig)))
const rtc = await useWebRTC().Init('slave')
// 表情词典树计算
const emoticonsTrie = computed(() => {
@@ -113,27 +120,27 @@ function setCss(css: string) {
/**
* 处理弹幕消息
*/
async function onAddText(data: DanmakuInfo, command: unknown) {
if (!config.value.showDanmaku || !filterTextMessage(data)) {
async function onAddText(event: EventModel, command: unknown) {
if (!config.value.showDanmaku || !filterTextMessage(event)) {
return
}
const richContent = await getRichContent(data)
const richContent = await getRichContent(event)
// 合并要放在异步调用后面,因为异步调用后可能有新的消息,会漏合并
if (mergeSimilarText(data.msg)) {
if (mergeSimilarText(event.msg)) {
return
}
const message = {
id: data.msg_id,
id: `msg-${new Date().getTime()}-${event.uid}`,
type: constants.MESSAGE_TYPE_TEXT,
avatarUrl: data.uface,
time: new Date(data.timestamp * 1000),
authorName: data.uname,
authorType: getAuthorType(data.open_id, data.guard_level),
content: data.msg,
avatarUrl: event.uface,
time: new Date(),
authorName: event.uname,
authorType: getAuthorType(event.open_id, event.guard_level),
content: event.msg,
richContent: richContent,
privilegeType: data.guard_level,
privilegeType: event.guard_level,
repeated: 1,
translation: ''
}
@@ -143,32 +150,32 @@ async function onAddText(data: DanmakuInfo, command: unknown) {
/**
* 处理礼物消息
*/
function onAddGift(data: GiftInfo, command: unknown) {
function onAddGift(event: EventModel, command: unknown) {
if (!config.value.showGift) {
return
}
const price = (data.price * data.gift_num) / 1000
const price = (event.price * event.num) / 1000
// 价格过滤
if (price < (config.value.minGiftPrice ?? 0)) {
return
}
// 尝试合并相似礼物
if (mergeSimilarGift(data.uname, price, !data.paid ? price : 0, data.gift_name, data.gift_num)) {
if (mergeSimilarGift(event.uname, price, !event.price ? price : 0, event.msg, event.num)) {
return
}
const message = {
id: data.msg_id,
id: `gift-${new Date().getTime()}-${event.uid}`,
type: constants.MESSAGE_TYPE_GIFT,
avatarUrl: data.uface,
time: new Date(data.timestamp * 1000),
authorName: data.uname,
authorNamePronunciation: getPronunciation(data.uname),
avatarUrl: event.uface,
time: new Date(),
authorName: event.uname,
authorNamePronunciation: getPronunciation(event.uname),
price: price,
giftName: data.gift_name,
num: data.gift_num
giftName: event.msg,
num: event.num
}
messageRender.value.addMessage(message)
}
@@ -176,19 +183,19 @@ function onAddGift(data: GiftInfo, command: unknown) {
/**
* 处理舰长上舰消息
*/
function onAddMember(data: GuardInfo, command: unknown) {
if (!config.value.showGift || !filterNewMemberMessage(data)) {
function onAddMember(event: EventModel, command: unknown) {
if (!config.value.showGift || !filterNewMemberMessage(event)) {
return
}
const message = {
id: data.msg_id,
id: `${event.type}-${new Date().getTime()}-${event.uid}`,
type: constants.MESSAGE_TYPE_MEMBER,
avatarUrl: data.user_info.uface,
time: new Date(data.timestamp * 1000),
authorName: data.user_info.uname,
authorNamePronunciation: getPronunciation(data.user_info.uname),
privilegeType: data.guard_level,
avatarUrl: event.uface,
time: new Date(),
authorName: event.uname,
authorNamePronunciation: getPronunciation(event.uname),
privilegeType: event.guard_level,
title: '新舰长'
}
messageRender.value.addMessage(message)
@@ -197,24 +204,24 @@ function onAddMember(data: GuardInfo, command: unknown) {
/**
* 处理醒目留言消息
*/
function onAddSuperChat(data: SCInfo) {
if (!config.value.showGift || !filterSuperChatMessage(data)) {
function onAddSuperChat(event: EventModel, command: unknown) {
if (!config.value.showGift || !filterSuperChatMessage(event)) {
return
}
if (data.rmb < (config.value.minGiftPrice ?? 0)) {
if (event.price < (config.value.minGiftPrice ?? 0)) {
return
}
const message = {
id: data.msg_id,
id: `${event.type}-${new Date().getTime()}-${event.uid}`,
type: constants.MESSAGE_TYPE_SUPER_CHAT,
avatarUrl: data.uface,
authorName: data.uname,
authorNamePronunciation: getPronunciation(data.uname),
price: data.rmb,
time: new Date(data.timestamp * 1000),
content: data.msg_id.trim(),
avatarUrl: event.uface,
authorName: event.uname,
authorNamePronunciation: getPronunciation(event.uname),
price: event.price,
time: new Date(),
content: event.msg.trim(),
translation: ''
}
messageRender.value.addMessage(message)
@@ -223,8 +230,31 @@ function onAddSuperChat(data: SCInfo) {
/**
* 处理SC撤回
*/
function onDelSuperChat(data: { id: string }) {
messageRender.value.deleteMessage(data.id)
function onDelSuperChat(event: EventModel, command: unknown) {
let messageIdsToDelete: string[] = [];
// 尝试从command中获取需要删除的SC ID
if (command && typeof command === 'object' && 'data' in command) {
const commandData = command.data;
if (commandData && typeof commandData === 'object') {
if ('message_ids' in commandData && Array.isArray(commandData.message_ids)) {
messageIdsToDelete = commandData.message_ids.map(id => String(id));
} else if ('message_id' in commandData) {
messageIdsToDelete.push(String(commandData.message_id));
}
}
}
// 尝试使用消息内容作为ID
else if (event.msg) {
messageIdsToDelete.push(event.msg);
}
if (messageIdsToDelete.length > 0) {
console.log(`正在删除SCID: ${messageIdsToDelete.join(', ')}`);
messageIdsToDelete.forEach(id => messageRender.value.deleteMessage(id));
} else {
console.warn("收到删除SC事件但无法确定要删除的消息ID", event, command);
}
}
/**
@@ -251,15 +281,15 @@ type RichContentType = {
/**
* 获取富文本内容(处理表情等)
*/
async function getRichContent(data: DanmakuInfo): Promise<RichContentType[]> {
async function getRichContent(data: EventModel): Promise<RichContentType[]> {
const richContent: RichContentType[] = []
// 官方的非文本表情
if (data.emoji_img_url) {
if (data.emoji) {
richContent.push({
type: constants.CONTENT_TYPE_IMAGE,
text: data.msg,
url: data.emoji_img_url + '@256w_256h_1e_1c',
url: data.emoji + '@256w_256h_1e_1c',
width: 256,
height: 256
})
@@ -379,15 +409,15 @@ function getPronunciation(text: string): string {
/**
* 过滤SC消息
*/
function filterSuperChatMessage(data: SCInfo): boolean {
return filterByContent(data.message) && filterByAuthorName(data.uname)
function filterSuperChatMessage(data: EventModel): boolean {
return filterByContent(data.msg) && filterByAuthorName(data.uname)
}
/**
* 过滤新舰长消息
*/
function filterNewMemberMessage(data: GuardInfo): boolean {
return filterByAuthorName(data.user_info.uname)
function filterNewMemberMessage(data: EventModel): boolean {
return filterByAuthorName(data.uname)
}
/**
@@ -407,13 +437,13 @@ function filterByContent(content: string): boolean {
* 根据用户名过滤消息
*/
function filterByAuthorName(id: string): boolean {
return !(id in accountInfo.value.biliBlackList)
return !(accountInfo.value && accountInfo.value.biliBlackList && id in accountInfo.value.biliBlackList)
}
/**
* 过滤弹幕消息
*/
function filterTextMessage(data: DanmakuInfo): boolean {
function filterTextMessage(data: EventModel): boolean {
// 舰长等级过滤
if (config.value.blockLevel > 0 && data.guard_level < config.value.blockLevel) {
return false
@@ -445,26 +475,139 @@ function mergeSimilarGift(authorName: string, price: number, freePrice: number,
return messageRender.value.mergeSimilarGift(authorName, price, freePrice, giftName, num)
}
// --- 修改测试方法 ---
/**
* 接收配置更新
* 用于测试,手动触发消息添加
* @param rawEventData 测试用的 EventModel 部分数据和可选的 data 负载
*/
function onReceiveConfig(data: DanmujiConfig) {
config.value = data
async function testAddMessage(rawEventData: Partial<EventModel> & { type: EventDataTypes, data?: any }) {
const event: EventModel = {
type: rawEventData.type,
uname: rawEventData.uname ?? '测试用户',
uface: rawEventData.uface ?? '',
uid: rawEventData.uid ?? 1000,
open_id: rawEventData.open_id ?? 'test_open_id',
msg: rawEventData.msg ?? '',
time: rawEventData.time ?? Date.now() / 1000,
num: rawEventData.num ?? 1,
price: rawEventData.price ?? 0,
guard_level: rawEventData.guard_level ?? 0,
fans_medal_level: rawEventData.fans_medal_level ?? 0,
fans_medal_name: rawEventData.fans_medal_name ?? '',
fans_medal_wearing_status: rawEventData.fans_medal_wearing_status ?? false,
emoji: rawEventData.emoji,
ouid: rawEventData.ouid ?? '',
...(rawEventData.data ? { data: rawEventData.data } : {})
};
switch (event.type) {
case EventDataTypes.Message:
await onAddText(event, null);
break;
case EventDataTypes.Gift:
onAddGift(event, null);
break;
case EventDataTypes.Guard:
onAddMember(event, null);
break;
case EventDataTypes.SC:
onAddSuperChat(event, null);
break;
case EventDataTypes.SCDel:
onDelSuperChat(event, null);
break;
default:
console.warn('Unsupported test event type:', event.type);
}
}
// --- 结束修改测试方法 ---
/**
* 添加系统通知消息
*/
function addSystemNotice(message: string) {
if (!messageRender.value) return;
const systemMessage = {
id: `system-${Date.now()}`,
type: constants.MESSAGE_TYPE_TEXT,
avatarUrl: '',
time: new Date(),
authorName: '系统通知',
authorType: 2, // 使用特殊类型标识系统消息
content: message,
richContent: [{
type: constants.CONTENT_TYPE_TEXT,
text: message
}],
privilegeType: 0,
repeated: 1,
translation: '',
isSystem: true // 添加标记以便在UI中特殊处理
}
messageRender.value.addMessage(systemMessage)
}
let configHashCheckTimer: ReturnType<typeof setInterval> | null = null;
let currentConfigHash: string | null = null;
// 从服务器获取配置
async function getConfigFromServer() {
try {
const result = await DownloadConfig<DanmujiConfig>('danmuji-config');
if (result.status === 'success' && result.data) {
Object.assign(config.value, result.data);
console.log('已从服务器获取弹幕姬配置');
addSystemNotice('配置已从服务器更新');
return true;
} else if (result.status === 'notfound') {
console.log('服务器上未找到弹幕姬配置');
} else {
console.error(`获取配置失败: ${result.msg}`);
}
} catch (error) {
console.error('获取配置文件出错:', error);
}
return false;
}
// 检查配置文件哈希值
async function checkConfigHash() {
if (!isOBS.value) return;
try {
const hash = await GetConfigHash('danmuji-config');
if (hash && hash !== currentConfigHash) {
console.log('配置文件已更新,正在获取新配置...');
currentConfigHash = hash;
await getConfigFromServer();
}
} catch (error) {
console.error('检查配置哈希值出错:', error);
}
}
// 启动定时检查配置
function startConfigHashCheck() {
if (!isOBS.value) return;
// 先获取一次当前哈希值
GetConfigHash('danmuji-config').then(hash => {
currentConfigHash = hash;
});
// 设置定时检查每5秒检查一次
configHashCheckTimer = setInterval(checkConfigHash, 5000);
}
onMounted(async () => {
// 注册事件监听
client.on('danmaku', onAddText)
client.on('gift', onAddGift)
client.on('sc', onAddSuperChat)
client.on('guard', onAddMember)
client.onEvent('danmaku', onAddText)
client.onEvent('gift', onAddGift)
client.onEvent('sc', onAddSuperChat)
client.onEvent('guard', onAddMember)
client.onEvent('scDel', onDelSuperChat)
// 注册RTC配置接收
if (rtc) {
rtc.on('danmuji.config', onReceiveConfig)
}
// 加载表情包
try {
const result = await QueryGetAPI<{ keyword: string, url: string }[]>(VTSURU_API_URL + 'blivechat/emoticon')
if (result.code === 200) {
@@ -473,18 +616,41 @@ onMounted(async () => {
} catch (error) {
console.error('加载表情包失败:', error)
}
// 监听CSS变化
watch(customCss, (newVal) => {
messageRender.value?.setCss(newVal)
})
// 显示弹幕姬加载完成的通知
setTimeout(() => {
addSystemNotice('加载完成');
}, 300);
// 在OBS环境下获取配置并启动配置检查
// @ts-ignore
if (window.obsstudio) {
await getConfigFromServer();
startConfigHashCheck();
messageRender.value?.setCss(defaultDanmujiCss)
console.log('设置默认CSS')
} else {
messageRender.value?.setCss(customCss.value)
}
})
onUnmounted(() => {
// 取消事件监听
client.off('danmaku', onAddText)
client.off('gift', onAddGift)
client.off('sc', onAddSuperChat)
client.off('guard', onAddMember)
client.offEvent('danmaku', onAddText)
client.offEvent('gift', onAddGift)
client.offEvent('sc', onAddSuperChat)
client.offEvent('guard', onAddMember)
client.offEvent('scDel', onDelSuperChat)
// 取消RTC配置接收
if (rtc) {
rtc.off('danmuji.config', onReceiveConfig)
// 清除定时器
if (configHashCheckTimer) {
clearInterval(configHashCheckTimer);
configHashCheckTimer = null;
}
})
</script>
@@ -503,4 +669,11 @@ onUnmounted(() => {
:show-gift-name="config.showGiftName"
style="height: 100%; width: 100%"
/>
</template>
</template>
<style scoped>
.body {
background-color: transparent;
}
</style>

View File

@@ -22,38 +22,34 @@
</yt-live-chat-author-badge-renderer>
</template>
<script>
import { NTooltip } from 'naive-ui';
<script setup>
import { computed } from 'vue'
import { NTooltip } from 'naive-ui'
import * as constants from './constants'
import { FILE_BASE_URL } from '@/data/constants';
import { FILE_BASE_URL } from '@/data/constants'
export default {
name: 'AuthorBadge',
props: {
isAdmin: Boolean,
privilegeType: Number
},
components: {
NTooltip
},
computed: {
authorTypeText() {
if (this.isAdmin) {
return 'moderator'
}
return this.privilegeType > 0 ? 'member' : ''
},
readableAuthorTypeText() {
if (this.isAdmin) {
return '管理员'
}
return constants.getShowGuardLevelText(this.privilegeType)
},
fileServerUrl() {
return FILE_BASE_URL
}
const props = defineProps({
isAdmin: Boolean,
privilegeType: Number
})
const authorTypeText = computed(() => {
if (props.isAdmin) {
return 'moderator'
}
}
return props.privilegeType > 0 ? 'member' : ''
})
const readableAuthorTypeText = computed(() => {
if (props.isAdmin) {
return '管理员'
}
return constants.getShowGuardLevelText(props.privilegeType)
})
const fileServerUrl = computed(() => {
return FILE_BASE_URL
})
</script>
<style src="@/assets/css/youtube/yt-live-chat-author-badge-renderer.css"></style>

View File

@@ -1,50 +1,63 @@
<template>
<yt-live-chat-author-chip>
<span id="author-name" dir="auto" class="style-scope yt-live-chat-author-chip"
:class="{ member: isInMemberMessage }" :type="authorTypeText">
<span
id="author-name"
dir="auto"
class="style-scope yt-live-chat-author-chip"
:class="{ member: isInMemberMessage }"
:type="authorTypeText"
>
{{ authorName }}
<!-- 这里是已验证勋章 -->
<span id="chip-badges" class="style-scope yt-live-chat-author-chip"></span>
<span
id="chip-badges"
class="style-scope yt-live-chat-author-chip"
/>
</span>
<span id="chat-badges" class="style-scope yt-live-chat-author-chip">
<author-badge v-if="isInMemberMessage" class="style-scope yt-live-chat-author-chip" :isAdmin="false"
:privilegeType="privilegeType"></author-badge>
<span
id="chat-badges"
class="style-scope yt-live-chat-author-chip"
>
<author-badge
v-if="isInMemberMessage"
class="style-scope yt-live-chat-author-chip"
:is-admin="false"
:privilege-type="privilegeType"
/>
<template v-else>
<author-badge v-if="authorType === AUTHOR_TYPE_ADMIN" class="style-scope yt-live-chat-author-chip" isAdmin
:privilegeType="0"></author-badge>
<author-badge v-if="privilegeType > 0" class="style-scope yt-live-chat-author-chip" :isAdmin="false"
:privilegeType="privilegeType"></author-badge>
<author-badge
v-if="authorType === AUTHOR_TYPE_ADMIN"
class="style-scope yt-live-chat-author-chip"
is-admin
:privilege-type="0"
/>
<author-badge
v-if="privilegeType > 0"
class="style-scope yt-live-chat-author-chip"
:is-admin="false"
:privilege-type="privilegeType"
/>
</template>
</span>
</yt-live-chat-author-chip>
</template>
<script>
import { defineComponent } from 'vue';
<script setup>
import { computed } from 'vue'
import AuthorBadge from './AuthorBadge.vue'
import * as constants from './constants'
export default defineComponent({
name: 'AuthorChip',
components: {
AuthorBadge
},
props: {
isInMemberMessage: Boolean,
authorName: String,
authorType: Number,
privilegeType: Number
},
data() {
return {
AUTHOR_TYPE_ADMIN: constants.AUTHOR_TYPE_ADMIN
}
},
computed: {
authorTypeText() {
return constants.AUTHOR_TYPE_TO_TEXT[this.authorType]
}
}
const props = defineProps({
isInMemberMessage: Boolean,
authorName: String,
authorType: Number,
privilegeType: Number
})
const AUTHOR_TYPE_ADMIN = constants.AUTHOR_TYPE_ADMIN
const authorTypeText = computed(() => {
return constants.AUTHOR_TYPE_TO_TEXT[props.authorType]
})
</script>

View File

@@ -1,36 +1,43 @@
<template>
<yt-img-shadow class="no-transition" :height="height" :width="width" style="background-color: transparent;" loaded>
<img id="img" class="style-scope yt-img-shadow" alt="" :height="height" :width="width" :src="showImgUrl"
@error="onLoadError" referrerpolicy="no-referrer">
<yt-img-shadow
class="no-transition"
:height="height"
:width="width"
style="background-color: transparent;"
loaded
>
<img
id="img"
class="style-scope yt-img-shadow"
alt=""
:height="height"
:width="width"
:src="showImgUrl"
referrerpolicy="no-referrer"
@error="onLoadError"
>
</yt-img-shadow>
</template>
<script>
<script setup>
import { ref, watch } from 'vue'
import * as models from '../../../data/chat/models'
export default {
name: 'ImgShadow',
props: {
imgUrl: String,
height: String,
width: String
},
data() {
return {
showImgUrl: this.imgUrl
}
},
watch: {
imgUrl(val) {
this.showImgUrl = val
}
},
methods: {
onLoadError() {
if (this.showImgUrl !== models.DEFAULT_AVATAR_URL) {
this.showImgUrl = models.DEFAULT_AVATAR_URL
}
}
const props = defineProps({
imgUrl: String,
height: String,
width: String
})
const showImgUrl = ref(props.imgUrl)
watch(() => props.imgUrl, (val) => {
showImgUrl.value = val
})
function onLoadError() {
if (showImgUrl.value !== models.DEFAULT_AVATAR_URL) {
showImgUrl.value = models.DEFAULT_AVATAR_URL
}
}
</script>

View File

@@ -1,52 +1,80 @@
<template>
<yt-live-chat-membership-item-renderer class="style-scope yt-live-chat-item-list-renderer" show-only-header
<yt-live-chat-membership-item-renderer
class="style-scope yt-live-chat-item-list-renderer"
show-only-header
:blc-guard-level="privilegeType"
>
<div id="card" class="style-scope yt-live-chat-membership-item-renderer">
<div id="header" class="style-scope yt-live-chat-membership-item-renderer">
<img-shadow id="author-photo" height="40" width="40" class="style-scope yt-live-chat-membership-item-renderer"
:imgUrl="avatarUrl"
></img-shadow>
<div id="header-content" class="style-scope yt-live-chat-membership-item-renderer">
<div id="header-content-primary-column" class="style-scope yt-live-chat-membership-item-renderer">
<div id="header-content-inner-column" class="style-scope yt-live-chat-membership-item-renderer">
<author-chip class="style-scope yt-live-chat-membership-item-renderer"
isInMemberMessage :authorName="authorName" :authorType="0" :privilegeType="privilegeType"
></author-chip>
<div
id="card"
class="style-scope yt-live-chat-membership-item-renderer"
>
<div
id="header"
class="style-scope yt-live-chat-membership-item-renderer"
>
<img-shadow
id="author-photo"
height="40"
width="40"
class="style-scope yt-live-chat-membership-item-renderer"
:img-url="avatarUrl"
/>
<div
id="header-content"
class="style-scope yt-live-chat-membership-item-renderer"
>
<div
id="header-content-primary-column"
class="style-scope yt-live-chat-membership-item-renderer"
>
<div
id="header-content-inner-column"
class="style-scope yt-live-chat-membership-item-renderer"
>
<author-chip
class="style-scope yt-live-chat-membership-item-renderer"
is-in-member-message
:author-name="authorName"
:author-type="0"
:privilege-type="privilegeType"
/>
</div>
<div
id="header-subtext"
class="style-scope yt-live-chat-membership-item-renderer"
>
{{ title }}
</div>
<div id="header-subtext" class="style-scope yt-live-chat-membership-item-renderer">{{ title }}</div>
</div>
<div id="timestamp" class="style-scope yt-live-chat-membership-item-renderer">{{ timeText }}</div>
<div
id="timestamp"
class="style-scope yt-live-chat-membership-item-renderer"
>
{{ timeText }}
</div>
</div>
</div>
</div>
</yt-live-chat-membership-item-renderer>
</template>
<script>
<script setup>
import { computed } from 'vue'
import ImgShadow from './ImgShadow.vue'
import AuthorChip from './AuthorChip.vue'
import * as utils from './utils'
export default {
name: 'MembershipItem',
components: {
ImgShadow,
AuthorChip
},
props: {
avatarUrl: String,
authorName: String,
privilegeType: Number,
title: String,
time: Date
},
computed: {
timeText() {
return utils.getTimeTextHourMin(this.time)
}
}
}
const props = defineProps({
avatarUrl: String,
authorName: String,
privilegeType: Number,
title: String,
time: Date
})
const timeText = computed(() => {
return utils.getTimeTextHourMin(props.time)
})
</script>
<style src="@/assets/css/youtube/yt-live-chat-membership-item-renderer.css"></style>

View File

@@ -1,6 +1,9 @@
<template>
<yt-live-chat-paid-message-renderer class="style-scope yt-live-chat-item-list-renderer" allow-animations
:show-only-header="!content || undefined" :style="{
<yt-live-chat-paid-message-renderer
class="style-scope yt-live-chat-item-list-renderer"
allow-animations
:show-only-header="!content || undefined"
:style="{
'--yt-live-chat-paid-message-primary-color': color.contentBg,
'--yt-live-chat-paid-message-secondary-color': color.headerBg,
'--yt-live-chat-paid-message-header-color': color.header,
@@ -10,59 +13,94 @@
}"
:blc-price-level="priceConfig.priceLevel"
>
<div id="card" class="style-scope yt-live-chat-paid-message-renderer">
<div id="header" class="style-scope yt-live-chat-paid-message-renderer">
<img-shadow id="author-photo" height="40" width="40" class="style-scope yt-live-chat-paid-message-renderer"
:imgUrl="avatarUrl"
></img-shadow>
<div id="header-content" class="style-scope yt-live-chat-paid-message-renderer">
<div id="header-content-primary-column" class="style-scope yt-live-chat-paid-message-renderer">
<div id="author-name" class="style-scope yt-live-chat-paid-message-renderer">{{ authorName }}</div>
<div id="purchase-amount" class="style-scope yt-live-chat-paid-message-renderer">{{ showPriceText }}</div>
<div
id="card"
class="style-scope yt-live-chat-paid-message-renderer"
>
<div
id="header"
class="style-scope yt-live-chat-paid-message-renderer"
>
<img-shadow
id="author-photo"
height="40"
width="40"
class="style-scope yt-live-chat-paid-message-renderer"
:img-url="avatarUrl"
/>
<div
id="header-content"
class="style-scope yt-live-chat-paid-message-renderer"
>
<div
id="header-content-primary-column"
class="style-scope yt-live-chat-paid-message-renderer"
>
<div
id="author-name"
class="style-scope yt-live-chat-paid-message-renderer"
>
{{ authorName }}
</div>
<div
id="purchase-amount"
class="style-scope yt-live-chat-paid-message-renderer"
>
{{ showPriceText }}
</div>
</div>
<span id="timestamp" class="style-scope yt-live-chat-paid-message-renderer">{{ timeText }}</span>
<span
id="timestamp"
class="style-scope yt-live-chat-paid-message-renderer"
>{{ timeText }}</span>
</div>
</div>
<div id="content" class="style-scope yt-live-chat-paid-message-renderer">
<div id="message" dir="auto" class="style-scope yt-live-chat-paid-message-renderer">{{ content }}</div>
<div
id="content"
class="style-scope yt-live-chat-paid-message-renderer"
>
<div
id="message"
dir="auto"
class="style-scope yt-live-chat-paid-message-renderer"
>
{{ content }}
</div>
</div>
</div>
</yt-live-chat-paid-message-renderer>
</template>
<script>
<script setup>
import { computed } from 'vue'
import ImgShadow from './ImgShadow.vue'
import * as constants from './constants'
import * as utils from './utils'
export default {
name: 'PaidMessage',
components: {
ImgShadow
},
props: {
avatarUrl: String,
authorName: String,
price: Number, // 价格,人民币
priceText: String,
time: Date,
content: String
},
computed: {
priceConfig() {
return constants.getPriceConfig(this.price)
},
color() {
return this.priceConfig.colors
},
showPriceText() {
return this.priceText || `CN¥${utils.formatCurrency(this.price)}`
},
timeText() {
return utils.getTimeTextHourMin(this.time)
}
}
}
const props = defineProps({
avatarUrl: String,
authorName: String,
price: Number, // 价格,人民币
priceText: String,
time: Date,
content: String
})
const priceConfig = computed(() => {
return constants.getPriceConfig(props.price)
})
const color = computed(() => {
return priceConfig.value.colors
})
const showPriceText = computed(() => {
return props.priceText || `CN¥${utils.formatCurrency(props.price)}`
})
const timeText = computed(() => {
return utils.getTimeTextHourMin(props.time)
})
</script>
<style src="@/assets/css/youtube/yt-live-chat-paid-message-renderer.css"></style>

View File

@@ -1,34 +1,68 @@
<template>
<yt-live-chat-text-message-renderer :author-type="authorTypeText" :blc-guard-level="privilegeType">
<img-shadow id="author-photo" height="24" width="24" class="style-scope yt-live-chat-text-message-renderer"
:imgUrl="avatarUrl"
></img-shadow>
<div id="content" class="style-scope yt-live-chat-text-message-renderer">
<span id="timestamp" class="style-scope yt-live-chat-text-message-renderer">{{ timeText }}</span>
<author-chip class="style-scope yt-live-chat-text-message-renderer"
:isInMemberMessage="false" :authorName="authorName" :authorType="authorType" :privilegeType="privilegeType"
></author-chip>
<span id="message" class="style-scope yt-live-chat-text-message-renderer">
<yt-live-chat-text-message-renderer
:author-type="authorTypeText"
:blc-guard-level="privilegeType"
>
<img-shadow
id="author-photo"
height="24"
width="24"
class="style-scope yt-live-chat-text-message-renderer"
:img-url="avatarUrl"
/>
<div
id="content"
class="style-scope yt-live-chat-text-message-renderer"
>
<span
id="timestamp"
class="style-scope yt-live-chat-text-message-renderer"
>{{ timeText }}</span>
<author-chip
class="style-scope yt-live-chat-text-message-renderer"
:is-in-member-message="false"
:author-name="authorName"
:author-type="authorType"
:privilege-type="privilegeType"
/>
<span
id="message"
class="style-scope yt-live-chat-text-message-renderer"
>
<template v-for="(content, index) in richContent">
<span :key="index" v-if="content.type === CONTENT_TYPE_TEXT">{{ content.text }}</span>
<span
v-if="content.type === CONTENT_TYPE_TEXT"
:key="index"
>{{ content.text }}</span>
<!-- 如果CSS设置的尺寸比属性设置的尺寸还大在图片加载完后布局会变化可能导致滚动卡住没什么好的解决方法 -->
<img :key="'_' + index" v-else-if="content.type === CONTENT_TYPE_IMAGE"
<img
v-else-if="content.type === CONTENT_TYPE_IMAGE"
:id="`emoji-${content.text}`"
:key="'_' + index"
class="emoji yt-formatted-string style-scope yt-live-chat-text-message-renderer"
:src="content.url" :alt="content.text" :shared-tooltip-text="content.text" :id="`emoji-${content.text}`"
:width="content.width" :height="content.height"
:src="content.url"
:alt="content.text"
:shared-tooltip-text="content.text"
:width="content.width"
:height="content.height"
:class="{ 'blc-large-emoji': content.height >= 100 }"
referrerpolicy="no-referrer"
>
</template>
<NBadge :value="repeated" :max="99" v-if="repeated > 1" class="style-scope yt-live-chat-text-message-renderer"
<NBadge
v-if="repeated > 1"
:value="repeated"
:max="99"
class="style-scope yt-live-chat-text-message-renderer"
:style="{ '--repeated-mark-color': repeatedMarkColor }"
></NBadge>
/>
</span>
</div>
</yt-live-chat-text-message-renderer>
</template>
<script>
<script setup>
import { computed } from 'vue'
import ImgShadow from './ImgShadow.vue'
import AuthorChip from './AuthorChip.vue'
import * as constants from './constants'
@@ -39,52 +73,42 @@ import { NBadge } from 'naive-ui'
const REPEATED_MARK_COLOR_START = [210, 100.0, 62.5]
const REPEATED_MARK_COLOR_END = [360, 87.3, 69.2]
export default {
name: 'TextMessage',
components: {
ImgShadow,
AuthorChip,
NBadge
},
props: {
avatarUrl: String,
time: Date,
authorName: String,
authorType: Number,
richContent: Array,
privilegeType: Number,
repeated: Number
},
data() {
return {
CONTENT_TYPE_TEXT: constants.CONTENT_TYPE_TEXT,
CONTENT_TYPE_IMAGE: constants.CONTENT_TYPE_IMAGE
}
},
computed: {
timeText() {
return utils.getTimeTextHourMin(this.time)
},
authorTypeText() {
return constants.AUTHOR_TYPE_TO_TEXT[this.authorType]
},
repeatedMarkColor() {
let color
if (this.repeated <= 2) {
color = REPEATED_MARK_COLOR_START
} else if (this.repeated >= 10) {
color = REPEATED_MARK_COLOR_END
} else {
color = [0, 0, 0]
let t = (this.repeated - 2) / (10 - 2)
for (let i = 0; i < 3; i++) {
color[i] = REPEATED_MARK_COLOR_START[i] + ((REPEATED_MARK_COLOR_END[i] - REPEATED_MARK_COLOR_START[i]) * t)
}
}
return `hsl(${color[0]}, ${color[1]}%, ${color[2]}%)`
const CONTENT_TYPE_TEXT = constants.CONTENT_TYPE_TEXT
const CONTENT_TYPE_IMAGE = constants.CONTENT_TYPE_IMAGE
const props = defineProps({
avatarUrl: String,
time: Date,
authorName: String,
authorType: Number,
richContent: Array,
privilegeType: Number,
repeated: Number
})
const timeText = computed(() => {
return utils.getTimeTextHourMin(props.time)
})
const authorTypeText = computed(() => {
return constants.AUTHOR_TYPE_TO_TEXT[props.authorType]
})
const repeatedMarkColor = computed(() => {
let color
if (props.repeated <= 2) {
color = REPEATED_MARK_COLOR_START
} else if (props.repeated >= 10) {
color = REPEATED_MARK_COLOR_END
} else {
color = [0, 0, 0]
let t = (props.repeated - 2) / (10 - 2)
for (let i = 0; i < 3; i++) {
color[i] = REPEATED_MARK_COLOR_START[i] + ((REPEATED_MARK_COLOR_END[i] - REPEATED_MARK_COLOR_START[i]) * t)
}
}
}
return `hsl(${color[0]}, ${color[1]}%, ${color[2]}%)`
})
</script>
<style>

View File

@@ -1,198 +1,243 @@
<!-- eslint-disable vue/multi-word-component-names -->
<template>
<yt-live-chat-ticker-renderer :hidden="showMessages.length === 0">
<div id="container" dir="ltr" class="style-scope yt-live-chat-ticker-renderer">
<transition-group tag="div" :css="false" @enter="onTickerItemEnter" @leave="onTickerItemLeave" id="items"
class="style-scope yt-live-chat-ticker-renderer">
<yt-live-chat-ticker-paid-message-item-renderer v-for="message in showMessages" :key="message.raw.id"
tabindex="0" class="style-scope yt-live-chat-ticker-renderer" style="overflow: hidden;"
@click="onItemClick(message.raw)">
<div id="container" dir="ltr" class="style-scope yt-live-chat-ticker-paid-message-item-renderer" :style="{
background: message.bgColor,
}">
<div id="content" class="style-scope yt-live-chat-ticker-paid-message-item-renderer" :style="{
color: message.color
}">
<img-shadow id="author-photo" height="24" width="24"
<div
id="container"
dir="ltr"
class="style-scope yt-live-chat-ticker-renderer"
>
<transition-group
id="items"
tag="div"
:css="false"
class="style-scope yt-live-chat-ticker-renderer"
@enter="onTickerItemEnter"
@leave="onTickerItemLeave"
>
<yt-live-chat-ticker-paid-message-item-renderer
v-for="message in showMessages"
:key="message.raw.id"
tabindex="0"
class="style-scope yt-live-chat-ticker-renderer"
style="overflow: hidden;"
@click="onItemClick(message.raw)"
>
<div
id="container"
dir="ltr"
class="style-scope yt-live-chat-ticker-paid-message-item-renderer"
:style="{
background: message.bgColor,
}"
>
<div
id="content"
class="style-scope yt-live-chat-ticker-paid-message-item-renderer"
:style="{
color: message.color
}"
>
<img-shadow
id="author-photo"
height="24"
width="24"
class="style-scope yt-live-chat-ticker-paid-message-item-renderer"
:imgUrl="message.raw.avatarUrl"></img-shadow>
<span id="text" dir="ltr"
class="style-scope yt-live-chat-ticker-paid-message-item-renderer">{{ message.text }}</span>
:img-url="message.raw.avatarUrl"
/>
<span
id="text"
dir="ltr"
class="style-scope yt-live-chat-ticker-paid-message-item-renderer"
>{{ message.text }}</span>
</div>
</div>
</yt-live-chat-ticker-paid-message-item-renderer>
</transition-group>
</div>
<template v-if="pinnedMessage">
<membership-item :key="pinnedMessage.id" v-if="pinnedMessage.type === MESSAGE_TYPE_MEMBER"
class="style-scope yt-live-chat-ticker-renderer" :avatarUrl="pinnedMessage.avatarUrl"
:authorName="getShowAuthorName(pinnedMessage)" :privilegeType="pinnedMessage.privilegeType"
:title="pinnedMessage.title" :time="pinnedMessage.time"></membership-item>
<paid-message :key="pinnedMessage.id" v-else class="style-scope yt-live-chat-ticker-renderer"
:price="pinnedMessage.price" :avatarUrl="pinnedMessage.avatarUrl" :authorName="getShowAuthorName(pinnedMessage)"
:time="pinnedMessage.time" :content="pinnedMessageShowContent"></paid-message>
<membership-item
v-if="pinnedMessage.type === MESSAGE_TYPE_MEMBER"
:key="pinnedMessage.id"
class="style-scope yt-live-chat-ticker-renderer"
:avatar-url="pinnedMessage.avatarUrl"
:author-name="getShowAuthorName(pinnedMessage)"
:privilege-type="pinnedMessage.privilegeType"
:title="pinnedMessage.title"
:time="pinnedMessage.time"
/>
<paid-message
v-else
:key="pinnedMessage.id"
class="style-scope yt-live-chat-ticker-renderer"
:price="pinnedMessage.price"
:avatar-url="pinnedMessage.avatarUrl"
:author-name="getShowAuthorName(pinnedMessage)"
:time="pinnedMessage.time"
:content="pinnedMessageShowContent"
/>
</template>
</yt-live-chat-ticker-renderer>
</template>
<script>
<script setup>
// @ts-nocheck
import { ref, computed, onBeforeUnmount, nextTick } from 'vue'
import { formatCurrency } from './utils'
import ImgShadow from './ImgShadow.vue'
import MembershipItem from './MembershipItem.vue'
import PaidMessage from './PaidMessage.vue'
import * as constants from './constants'
export default {
name: 'Ticker',
components: {
ImgShadow,
MembershipItem,
PaidMessage
},
props: {
messages: Array,
showGiftName: {
type: Boolean,
default: false
}
},
data() {
return {
MESSAGE_TYPE_MEMBER: constants.MESSAGE_TYPE_MEMBER,
const props = defineProps({
messages: Array,
showGiftName: {
type: Boolean,
default: false
}
})
curTime: new Date(),
updateTimerId: window.setInterval(this.updateProgress, 1000),
pinnedMessage: null
}
},
computed: {
showMessages() {
let res = []
for (let message of this.messages) {
if (!this.needToShow(message)) {
continue
}
res.push({
raw: message,
bgColor: this.getBgColor(message),
color: this.getColor(message),
text: this.getText(message)
})
}
return res
},
pinnedMessageShowContent() {
if (!this.pinnedMessage) {
return ''
}
if (this.pinnedMessage.type === constants.MESSAGE_TYPE_GIFT) {
return constants.getGiftShowContent(this.pinnedMessage, this.showGiftName)
} else {
return constants.getShowContent(this.pinnedMessage)
}
}
},
beforeDestroy() {
window.clearInterval(this.updateTimerId)
},
methods: {
async onTickerItemEnter(el, done) {
let width = el.clientWidth
if (width === 0) {
// CSS指定了不显示固定栏
done()
return
}
el.style.width = 0
await this.$nextTick()
el.style.width = `${width}px`
window.setTimeout(done, 200)
},
onTickerItemLeave(el, done) {
el.classList.add('sliding-down')
window.setTimeout(() => {
el.classList.add('collapsing')
el.style.width = 0
window.setTimeout(() => {
el.classList.remove('sliding-down')
el.classList.remove('collapsing')
el.style.width = 'auto'
done()
}, 200)
}, 200)
},
const emit = defineEmits(['update:messages'])
getShowAuthorName: constants.getShowAuthorName,
needToShow(message) {
let pinTime = this.getPinTime(message)
return (new Date() - message.addTime) / (60 * 1000) < pinTime
},
getBgColor(message) {
let color1, color2
if (message.type === constants.MESSAGE_TYPE_MEMBER) {
color1 = 'rgba(15,157,88,1)'
color2 = 'rgba(11,128,67,1)'
} else {
let config = constants.getPriceConfig(message.price)
color1 = config.colors.contentBg
color2 = config.colors.headerBg
}
let pinTime = this.getPinTime(message)
let progress = (1 - ((this.curTime - message.addTime) / (60 * 1000) / pinTime)) * 100
if (progress < 0) {
progress = 0
} else if (progress > 100) {
progress = 100
}
return `linear-gradient(90deg, ${color1}, ${color1} ${progress}%, ${color2} ${progress}%, ${color2})`
},
getColor(message) {
if (message.type === constants.MESSAGE_TYPE_MEMBER) {
return 'rgb(255,255,255)'
}
return constants.getPriceConfig(message.price).colors.header
},
getText(message) {
if (message.type === constants.MESSAGE_TYPE_MEMBER) {
return this.$t('chat.tickerMembership')
}
return `CN¥${formatCurrency(message.price)}`
},
getPinTime(message) {
if (message.type === constants.MESSAGE_TYPE_MEMBER) {
return 2
}
return constants.getPriceConfig(message.price).pinTime
},
updateProgress() {
// 更新进度
this.curTime = new Date()
const MESSAGE_TYPE_MEMBER = constants.MESSAGE_TYPE_MEMBER
const curTime = ref(new Date())
const pinnedMessage = ref(null)
// 删除过期的消息
let filteredMessages = []
let messagesChanged = false
for (let message of this.messages) {
let pinTime = this.getPinTime(message)
if ((this.curTime - message.addTime) / (60 * 1000) >= pinTime) {
messagesChanged = true
if (this.pinnedMessage === message) {
this.pinnedMessage = null
}
continue
}
filteredMessages.push(message)
}
if (messagesChanged) {
this.$emit('update:messages', filteredMessages)
}
},
onItemClick(message) {
if (this.pinnedMessage == message) {
this.pinnedMessage = null
} else {
this.pinnedMessage = message
}
// 定时更新进度
const updateTimerId = window.setInterval(updateProgress, 1000)
onBeforeUnmount(() => {
window.clearInterval(updateTimerId)
})
const showMessages = computed(() => {
let res = []
for (let message of props.messages) {
if (!needToShow(message)) {
continue
}
res.push({
raw: message,
bgColor: getBgColor(message),
color: getColor(message),
text: getText(message)
})
}
return res
})
const pinnedMessageShowContent = computed(() => {
if (!pinnedMessage.value) {
return ''
}
if (pinnedMessage.value.type === constants.MESSAGE_TYPE_GIFT) {
return constants.getGiftShowContent(pinnedMessage.value, props.showGiftName)
} else {
return constants.getShowContent(pinnedMessage.value)
}
})
async function onTickerItemEnter(el, done) {
let width = el.clientWidth
if (width === 0) {
// CSS指定了不显示固定栏
done()
return
}
el.style.width = 0
await nextTick()
el.style.width = `${width}px`
window.setTimeout(done, 200)
}
function onTickerItemLeave(el, done) {
el.classList.add('sliding-down')
window.setTimeout(() => {
el.classList.add('collapsing')
el.style.width = 0
window.setTimeout(() => {
el.classList.remove('sliding-down')
el.classList.remove('collapsing')
el.style.width = 'auto'
done()
}, 200)
}, 200)
}
const getShowAuthorName = constants.getShowAuthorName
function needToShow(message) {
let pinTime = getPinTime(message)
return (new Date() - message.addTime) / (60 * 1000) < pinTime
}
function getBgColor(message) {
let color1, color2
if (message.type === constants.MESSAGE_TYPE_MEMBER) {
color1 = 'rgba(15,157,88,1)'
color2 = 'rgba(11,128,67,1)'
} else {
let config = constants.getPriceConfig(message.price)
color1 = config.colors.contentBg
color2 = config.colors.headerBg
}
let pinTime = getPinTime(message)
let progress = (1 - ((curTime.value - message.addTime) / (60 * 1000) / pinTime)) * 100
if (progress < 0) {
progress = 0
} else if (progress > 100) {
progress = 100
}
return `linear-gradient(90deg, ${color1}, ${color1} ${progress}%, ${color2} ${progress}%, ${color2})`
}
function getColor(message) {
if (message.type === constants.MESSAGE_TYPE_MEMBER) {
return 'rgb(255,255,255)'
}
return constants.getPriceConfig(message.price).colors.header
}
function getText(message) {
if (message.type === constants.MESSAGE_TYPE_MEMBER) {
return '舰长'
}
return `CN¥${formatCurrency(message.price)}`
}
function getPinTime(message) {
if (message.type === constants.MESSAGE_TYPE_MEMBER) {
return 2
}
return constants.getPriceConfig(message.price).pinTime
}
function updateProgress() {
// 更新进度
curTime.value = new Date()
// 删除过期的消息
let filteredMessages = []
let messagesChanged = false
for (let message of props.messages) {
let pinTime = getPinTime(message)
if ((curTime.value - message.addTime) / (60 * 1000) >= pinTime) {
messagesChanged = true
if (pinnedMessage.value === message) {
pinnedMessage.value = null
}
continue
}
filteredMessages.push(message)
}
if (messagesChanged) {
emit('update:messages', filteredMessages)
}
}
function onItemClick(message) {
if (pinnedMessage.value == message) {
pinnedMessage.value = null
} else {
pinnedMessage.value = message
}
}
</script>

View File

@@ -1,4 +1,3 @@
export const AUTHOR_TYPE_NORMAL = 0
export const AUTHOR_TYPE_MEMBER = 1
export const AUTHOR_TYPE_ADMIN = 2
@@ -181,6 +180,17 @@ export function getShowRichContent(message) {
return richContent
}
export function getShowContentParts(message) {
let contentParts = [...message.contentParts || []]
if (message.translation) {
contentParts.push({
type: CONTENT_TYPE_TEXT,
text: `${message.translation}`
})
}
return contentParts
}
export function getGiftShowContent(message, showGiftName) {
if (!showGiftName) {
return ''

View File

@@ -1,3 +1,6 @@
import { format } from 'date-fns'
export function mergeConfig(config, defaultConfig) {
let res = {}
for (let i in defaultConfig) {
@@ -36,9 +39,7 @@ export function formatCurrency(price) {
}
export function getTimeTextHourMin(date) {
let hour = date.getHours()
let min = `00${date.getMinutes()}`.slice(-2)
return `${hour}:${min}`
return format(date, 'H:mm')
}
export function getUuid4Hex() {

View File

@@ -129,10 +129,44 @@ const selectedItems = computed(() => {
// --- 方法 ---
// 获取礼物兑换按钮的提示文本
function getTooltip(goods: ResponsePointGoodModel): '开始兑换' | '当前积分不足' | '请先进行账号认证' | '库存不足' {
function getTooltip(goods: ResponsePointGoodModel): '开始兑换' | '当前积分不足' | '请先进行账号认证' | '库存不足' | '舰长等级不足' | '兑换时间未到' | '已达兑换上限' | '需要设置地址' {
if (!biliAuth.value.id) return '请先进行账号认证' // 未认证
if ((goods?.count ?? Number.MAX_VALUE) <= 0) return '库存不足' // 库存不足
if ((currentPoint.value ?? 0) < goods.price) return '当前积分不足' // 积分不足
if ((currentPoint.value ?? 0) < goods.price && !goods.canFreeBuy) return '当前积分不足' // 积分不足且不能免费兑换
// 检查舰长等级要求
// 使用 guardInfo 判断用户在当前主播房间的舰长等级
const currentGuardLevel = biliAuth.value.guardInfo?.[props.userInfo.id] ?? 0
if (goods.allowGuardLevel > 0 && currentGuardLevel < goods.allowGuardLevel) {
return '舰长等级不足'
}
// 在当前模型中没有兑换时间限制字段,可以根据需要添加相关功能
// 如果将来添加了时间限制功能,可以取消下面注释并调整代码
/*
if (goods.startTime && new Date() < new Date(goods.startTime)) {
return '兑换时间未到'
}
if (goods.endTime && new Date() > new Date(goods.endTime)) {
return '兑换已结束'
}
*/
// 检查用户兑换上限
// 注意:当前模型中没有 userBoughtCount 属性,
// 需要后端提供已购买数量信息才能实现此功能
/*
if (goods.userBoughtCount !== undefined && goods.maxBuyCount !== undefined &&
goods.userBoughtCount >= goods.maxBuyCount && goods.maxBuyCount > 0) {
return '已达兑换上限'
}
*/
// 检查实物礼物的地址要求
if (goods.type === GoodsTypes.Physical && !goods.collectUrl &&
(!biliAuth.value.address || biliAuth.value.address.length === 0)) {
return '需要设置地址'
}
return '开始兑换' // 可以兑换
}
@@ -420,7 +454,7 @@ onMounted(async () => {
<NSelect
v-model:value="priceOrder"
:options="[
{ label: '默认排序', value: null },
{ label: '默认排序', value: 'null' },
{ label: '价格 低→高', value: 'asc' },
{ label: '价格 高→低', value: 'desc' }
]"

View File

@@ -1,4 +1,4 @@
function TestVIneComponent() {
function TestVineComponent() {
return vine`
<div>
<h1>Test Vine</h1>
@@ -6,7 +6,8 @@ function TestVIneComponent() {
<p>Vine is a new way to build web applications.</p>
<p>Enjoy building with Vine!</p>
<footer>Footer content goes here.</footer>
</div>`
</div>
`
}
export default TestVIneComponent
export default TestVineComponent