mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-07 02:46:55 +08:00
feat: 更新项目配置和依赖,增强功能和用户体验
- 完成弹幕机功能 - 在 .editorconfig 中新增对 vine.ts 文件的支持。 - 更新 package.json 中多个依赖的版本,提升稳定性和性能。 - 在 vite.config.mts 中引入 @guolao/vue-monaco-editor 插件,增强代码编辑功能。 - 在 App.vue 中调整内容填充的样式,优化界面布局。 - 新增获取配置文件哈希的 API 方法,提升配置管理能力。 - 在多个组件中优化了样式和逻辑,提升用户交互体验。
This commit is contained in:
@@ -243,7 +243,7 @@ onMounted(async () => {
|
||||
style="width: 100%"
|
||||
>
|
||||
<NAlert type="success">
|
||||
你已完成验证! 请妥善保存你的登陆链接, 请勿让其他人获取. 丢失后可以再次通过认证流程获得
|
||||
你已完成验证! 请妥善保存你的登陆链接, 请勿让其他人获取. 丢失后可以再次通过认证流程获得. 把这个链接复制到浏览器打开即可登录
|
||||
</NAlert>
|
||||
<NText> 你的登陆链接为: </NText>
|
||||
<NInputGroup>
|
||||
|
||||
@@ -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
@@ -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(`正在删除SC,ID: ${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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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' }
|
||||
]"
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user