mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-06 18:36:55 +08:00
feat: 优化弹幕动效, 开始自动操作编写
This commit is contained in:
@@ -550,7 +550,7 @@ export enum QueueStatus {
|
||||
}
|
||||
export interface EventModel {
|
||||
type: EventDataTypes
|
||||
name: string
|
||||
uname: string
|
||||
uface: string
|
||||
uid: number
|
||||
open_id: string
|
||||
|
||||
93
src/client/ClientAutoAction.vue
Normal file
93
src/client/ClientAutoAction.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { NTabs, NTabPane, NCard, NSpace, NScrollbar } from 'naive-ui';
|
||||
import { useAutoAction } from '@/client/store/useAutoAction';
|
||||
import GiftThankConfig from './components/autoaction/GiftThankConfig.vue';
|
||||
import GuardPmConfig from './components/autoaction/GuardPmConfig.vue';
|
||||
import FollowThankConfig from './components/autoaction/FollowThankConfig.vue';
|
||||
import EntryWelcomeConfig from './components/autoaction/EntryWelcomeConfig.vue';
|
||||
import ScheduledDanmakuConfig from './components/autoaction/ScheduledDanmakuConfig.vue';
|
||||
import AutoReplyConfig from './components/autoaction/AutoReplyConfig.vue';
|
||||
|
||||
const autoActionStore = useAutoAction();
|
||||
|
||||
// 当前激活的标签页
|
||||
const activeTab = ref('gift-thank');
|
||||
|
||||
// 在组件挂载后初始化自动操作模块
|
||||
onMounted(() => {
|
||||
//autoActionStore.init();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCard
|
||||
title="自动操作设置"
|
||||
size="small"
|
||||
>
|
||||
<NAlert
|
||||
type="warning"
|
||||
show-icon
|
||||
closable
|
||||
style="margin-bottom: 16px;"
|
||||
>
|
||||
施工中
|
||||
</NAlert>
|
||||
<NTabs
|
||||
v-model:value="activeTab"
|
||||
type="line"
|
||||
animated
|
||||
>
|
||||
<NTabPane
|
||||
name="gift-thank"
|
||||
tab="礼物感谢"
|
||||
>
|
||||
<GiftThankConfig :config="autoActionStore.giftThankConfig" />
|
||||
</NTabPane>
|
||||
|
||||
<NTabPane
|
||||
name="guard-pm"
|
||||
tab="上舰私信"
|
||||
>
|
||||
<GuardPmConfig :config="autoActionStore.guardPmConfig" />
|
||||
</NTabPane>
|
||||
|
||||
<NTabPane
|
||||
name="follow-thank"
|
||||
tab="关注感谢"
|
||||
>
|
||||
<FollowThankConfig :config="autoActionStore.followThankConfig" />
|
||||
</NTabPane>
|
||||
|
||||
<NTabPane
|
||||
name="entry-welcome"
|
||||
tab="入场欢迎"
|
||||
>
|
||||
<EntryWelcomeConfig :config="autoActionStore.entryWelcomeConfig" />
|
||||
</NTabPane>
|
||||
|
||||
<NTabPane
|
||||
name="scheduled-danmaku"
|
||||
tab="定时弹幕"
|
||||
>
|
||||
<ScheduledDanmakuConfig :config="autoActionStore.scheduledDanmakuConfig" />
|
||||
</NTabPane>
|
||||
|
||||
<NTabPane
|
||||
name="auto-reply"
|
||||
tab="自动回复"
|
||||
>
|
||||
<AutoReplyConfig :config="autoActionStore.autoReplyConfig" />
|
||||
</NTabPane>
|
||||
</NTabs>
|
||||
</NCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.config-description {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
@@ -5,11 +5,13 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import ClientDanmakuItem from './ClientDanmakuItem.vue';
|
||||
import { TransitionGroup } from 'vue'; // 添加TransitionGroup导入
|
||||
|
||||
type TempDanmakuType = EventModel & {
|
||||
randomId: string;
|
||||
isNew?: boolean; // 添加:标记是否为新弹幕
|
||||
disappearAt?: number; // 消失时间戳
|
||||
timestamp?: number; // 添加:记录插入时间戳
|
||||
};
|
||||
|
||||
let bc: BroadcastChannel | undefined = undefined;
|
||||
@@ -17,6 +19,8 @@
|
||||
const danmakuList = ref<TempDanmakuType[]>([]);
|
||||
const maxItems = computed(() => setting.value?.maxDanmakuCount || 50);
|
||||
const hasItems = computed(() => danmakuList.value.length > 0);
|
||||
const isInBatchUpdate = ref(false); // 添加批量更新状态标志
|
||||
const pendingRemovalItems = ref<string[]>([]); // 待移除的弹幕ID
|
||||
|
||||
// 动态设置CSS变量
|
||||
function updateCssVariables() {
|
||||
@@ -67,18 +71,51 @@
|
||||
disappearAt = Date.now() + setting.value.autoDisappearTime * 1000;
|
||||
}
|
||||
|
||||
// 判断短时间内是否有大量弹幕插入
|
||||
const isRapidInsertion = danmakuList.value.filter(item =>
|
||||
item.isNew && Date.now() - (item.timestamp || 0) < 500).length > 5;
|
||||
|
||||
if (isRapidInsertion && !isInBatchUpdate.value) {
|
||||
isInBatchUpdate.value = true;
|
||||
// 在大量插入时简化动画,300ms后恢复
|
||||
setTimeout(() => {
|
||||
isInBatchUpdate.value = false;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// 为传入的弹幕对象添加一个随机ID和isNew标记
|
||||
const dataWithId = {
|
||||
...data,
|
||||
randomId: nanoid(), // 生成一个随机ID
|
||||
disappearAt, // 添加消失时间
|
||||
isNew: true, // 标记为新弹幕,用于动画
|
||||
timestamp: Date.now(), // 添加时间戳记录插入时间
|
||||
};
|
||||
|
||||
danmakuList.value.unshift(dataWithId);
|
||||
// Limit the list size AFTER adding the new item
|
||||
while (danmakuList.value.length > maxItems.value) {
|
||||
danmakuList.value.pop();
|
||||
|
||||
// 优化超出长度的弹幕处理 - 改为标记并动画方式移除
|
||||
if (danmakuList.value.length > maxItems.value) {
|
||||
// 找到要移除的项目
|
||||
const itemsToRemove = danmakuList.value.slice(maxItems.value);
|
||||
itemsToRemove.forEach(item => {
|
||||
if (!pendingRemovalItems.value.includes(item.randomId)) {
|
||||
pendingRemovalItems.value.push(item.randomId);
|
||||
}
|
||||
});
|
||||
|
||||
// 延迟移除,给动画足够时间
|
||||
setTimeout(() => {
|
||||
danmakuList.value = danmakuList.value.filter(item =>
|
||||
!pendingRemovalItems.value.includes(item.randomId) ||
|
||||
item.timestamp && Date.now() - item.timestamp < setting.value!.animationDuration
|
||||
);
|
||||
|
||||
// 更新待移除列表
|
||||
pendingRemovalItems.value = pendingRemovalItems.value.filter(id =>
|
||||
danmakuList.value.some(item => item.randomId === id)
|
||||
);
|
||||
}, setting.value.animationDuration || 300);
|
||||
}
|
||||
|
||||
// 设置一个定时器,在动画完成后移除isNew标记
|
||||
@@ -97,11 +134,20 @@
|
||||
if (!setting.value || setting.value.autoDisappearTime <= 0) return;
|
||||
|
||||
const now = Date.now();
|
||||
// 让弹幕有足够时间完成消失动画后再从列表中移除
|
||||
danmakuList.value = danmakuList.value.filter(item => {
|
||||
// 如果设置了消失时间,则在消失时间+动画时长后才真正移除
|
||||
const animationDuration = setting.value?.animationDuration || 300;
|
||||
return !item.disappearAt || (item.disappearAt + animationDuration) > now;
|
||||
const animationDuration = setting.value.animationDuration || 300;
|
||||
|
||||
// 先标记将要消失的弹幕
|
||||
danmakuList.value.forEach(item => {
|
||||
if (item.disappearAt && item.disappearAt <= now && !pendingRemovalItems.value.includes(item.randomId)) {
|
||||
// 标记为待移除,但还不实际移除
|
||||
pendingRemovalItems.value.push(item.randomId);
|
||||
|
||||
// 延迟删除,让动画有时间完成
|
||||
setTimeout(() => {
|
||||
danmakuList.value = danmakuList.value.filter(d => d.randomId !== item.randomId);
|
||||
pendingRemovalItems.value = pendingRemovalItems.value.filter(id => id !== item.randomId);
|
||||
}, animationDuration);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -174,27 +220,34 @@
|
||||
<div
|
||||
v-else
|
||||
class="danmaku-window"
|
||||
:class="{ 'has-items': hasItems }"
|
||||
:class="{ 'has-items': hasItems, 'batch-update': isInBatchUpdate }"
|
||||
>
|
||||
<div
|
||||
ref="scrollContainerRef"
|
||||
class="danmaku-list"
|
||||
>
|
||||
<!-- 移除 TransitionGroup,使用普通 div -->
|
||||
<div class="danmaku-list-container">
|
||||
<!-- 使用TransitionGroup替代普通div -->
|
||||
<TransitionGroup
|
||||
name="danmaku-list"
|
||||
tag="div"
|
||||
class="danmaku-list-container"
|
||||
>
|
||||
<div
|
||||
v-for="item in danmakuList"
|
||||
:key="item.randomId"
|
||||
:data-type="item.type"
|
||||
class="danmaku-item"
|
||||
:class="{ 'danmaku-item-new': item.isNew }"
|
||||
:class="{
|
||||
'danmaku-item-leaving': pendingRemovalItems.includes(item.randomId),
|
||||
'batch-item': isInBatchUpdate
|
||||
}"
|
||||
>
|
||||
<ClientDanmakuItem
|
||||
:item="item"
|
||||
:setting="setting"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -257,6 +310,7 @@
|
||||
gap: var(--dw-item-spacing);
|
||||
padding-bottom: 8px; /* 添加底部内边距以防止项目溢出 */
|
||||
box-sizing: border-box; /* 确保padding不会增加元素的实际尺寸 */
|
||||
position: relative; /* 为TransitionGroup添加相对定位 */
|
||||
}
|
||||
|
||||
.danmaku-list.reverse {
|
||||
@@ -276,35 +330,101 @@
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 弹幕进入动画 */
|
||||
/* 弹幕项样式 */
|
||||
.danmaku-item {
|
||||
transform-origin: center left;
|
||||
transition: all var(--dw-animation-duration) ease;
|
||||
/* 添加硬件加速,防止文字模糊 */
|
||||
transform: translateZ(0);
|
||||
will-change: transform, opacity;
|
||||
backface-visibility: hidden;
|
||||
-webkit-font-smoothing: subpixel-antialiased;
|
||||
}
|
||||
|
||||
.danmaku-item-new {
|
||||
animation: danmaku-in var(--dw-animation-duration) ease-out forwards;
|
||||
/* 正在离开的弹幕项样式 */
|
||||
.danmaku-item-leaving {
|
||||
animation: danmaku-leave var(--dw-animation-duration) cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||
opacity: 0.8; /* 轻微降低不透明度,提高视觉层次感 */
|
||||
z-index: -1; /* 确保离开的项在其他项下方 */
|
||||
}
|
||||
|
||||
@keyframes danmaku-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
to {
|
||||
/* 批量更新模式下的优化 */
|
||||
.batch-update .danmaku-list-move {
|
||||
transition-duration: 100ms !important;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important;
|
||||
}
|
||||
|
||||
.batch-item {
|
||||
transition-duration: 100ms !important;
|
||||
}
|
||||
|
||||
/* TransitionGroup动画效果 */
|
||||
.danmaku-list-enter-active,
|
||||
.danmaku-list-leave-active,
|
||||
.danmaku-list-move {
|
||||
transition: all var(--dw-animation-duration) cubic-bezier(0.55, 0, 0.1, 1);
|
||||
/* 确保动画过程中文字不模糊 */
|
||||
transform: translateZ(0);
|
||||
will-change: transform, opacity;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.danmaku-list-leave-active {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.danmaku-list-enter-from {
|
||||
opacity: 0;
|
||||
transform: scaleY(0.5) translateX(-30px) translateZ(0);
|
||||
}
|
||||
|
||||
.danmaku-list-enter-to {
|
||||
opacity: 1;
|
||||
transform: scaleY(1) translateX(0) translateZ(0);
|
||||
}
|
||||
|
||||
.danmaku-list-leave-to {
|
||||
opacity: 0;
|
||||
transform: scaleY(0.5) translateX(30px) translateZ(0);
|
||||
}
|
||||
|
||||
/* 处理已有弹幕的移动动画 */
|
||||
.danmaku-list-move {
|
||||
transition: transform var(--dw-animation-duration) cubic-bezier(0.55, 0, 0.1, 1);
|
||||
/* 确保移动动画时文字不模糊 */
|
||||
backface-visibility: hidden;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
/* 根据弹幕类型提供不同的动画特性 */
|
||||
[data-type="3"] { /* 普通弹幕 */
|
||||
--transition-delay: 0.02s;
|
||||
}
|
||||
|
||||
[data-type="2"] { /* 礼物 */
|
||||
--transition-delay: 0.04s;
|
||||
animation-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1); /* 小弹跳效果 */
|
||||
}
|
||||
|
||||
[data-type="1"] { /* SC */
|
||||
--transition-delay: 0.05s;
|
||||
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); /* 特殊强调效果 */
|
||||
}
|
||||
|
||||
/* 添加弹幕消失动画 */
|
||||
@keyframes danmaku-leave {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
transform: translateX(0) translateZ(0);
|
||||
filter: blur(0px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes danmaku-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
transform: translateX(30px) translateZ(0);
|
||||
filter: blur(1px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -542,6 +542,89 @@
|
||||
</NFlex>
|
||||
</NCard>
|
||||
|
||||
<!-- Overall Status & Connection Details -->
|
||||
<NCard
|
||||
title="运行状态 & 连接"
|
||||
embedded
|
||||
style="width: 100%; max-width: 800px;"
|
||||
>
|
||||
<template #header-extra>
|
||||
<NTag
|
||||
:type="connectionStatusType"
|
||||
size="small"
|
||||
>
|
||||
<template #icon>
|
||||
<NIcon :component="isConnected ? CheckmarkCircleOutline : AlertCircleOutline" />
|
||||
</template>
|
||||
{{ connectionStatusText }}
|
||||
</NTag>
|
||||
</template>
|
||||
<NDescriptions
|
||||
label-placement="top"
|
||||
bordered
|
||||
:columns="2"
|
||||
size="small"
|
||||
style="overflow-x: auto;"
|
||||
>
|
||||
<NDescriptionsItem label="启动时间">
|
||||
<NIcon :component="TimeOutline" /> {{ formattedStartedAt }}
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="运行时长">
|
||||
<NIcon :component="TimerOutline" /> {{ uptime }}
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="SignalR 服务">
|
||||
<NFlex
|
||||
align="center"
|
||||
size="small"
|
||||
:wrap="false"
|
||||
>
|
||||
<NTag
|
||||
:type="signalRStateType"
|
||||
size="tiny"
|
||||
>
|
||||
{{ signalRStateText }}
|
||||
</NTag>
|
||||
<NEllipsis style="max-width: 150px;">
|
||||
{{ webfetcher.signalRId ?? 'N/A' }}
|
||||
</NEllipsis>
|
||||
</NFlex>
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="弹幕服务器">
|
||||
<NFlex
|
||||
align="center"
|
||||
size="small"
|
||||
:wrap="false"
|
||||
>
|
||||
<NTag
|
||||
:type="danmakuClientStateType"
|
||||
size="tiny"
|
||||
>
|
||||
{{ danmakuClientStateText }}
|
||||
</NTag>
|
||||
<NEllipsis style="max-width: 150px;">
|
||||
{{ webfetcher.danmakuServerUrl ?? 'N/A' }}
|
||||
</NEllipsis> <!-- Assuming this is exposed -->
|
||||
</NFlex>
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="网络状态">
|
||||
<NFlex
|
||||
align="center"
|
||||
size="small"
|
||||
>
|
||||
<NTag
|
||||
:type="networkStatus === 'online' ? 'success' : 'error'"
|
||||
size="tiny"
|
||||
>
|
||||
<template #icon>
|
||||
<NIcon :component="WifiOutline" />
|
||||
</template>
|
||||
{{ networkStatus === 'online' ? '在线' : '离线' }}
|
||||
</NTag>
|
||||
</NFlex>
|
||||
</NDescriptionsItem>
|
||||
</NDescriptions>
|
||||
</NCard>
|
||||
|
||||
<!-- Credentials & Account -->
|
||||
<NCard
|
||||
title="凭据 & 账户"
|
||||
@@ -787,89 +870,6 @@
|
||||
</NFlex>
|
||||
</NCard>
|
||||
|
||||
<!-- Overall Status & Connection Details -->
|
||||
<NCard
|
||||
title="运行状态 & 连接"
|
||||
embedded
|
||||
style="width: 100%; max-width: 800px;"
|
||||
>
|
||||
<template #header-extra>
|
||||
<NTag
|
||||
:type="connectionStatusType"
|
||||
size="small"
|
||||
>
|
||||
<template #icon>
|
||||
<NIcon :component="isConnected ? CheckmarkCircleOutline : AlertCircleOutline" />
|
||||
</template>
|
||||
{{ connectionStatusText }}
|
||||
</NTag>
|
||||
</template>
|
||||
<NDescriptions
|
||||
label-placement="top"
|
||||
bordered
|
||||
:columns="2"
|
||||
size="small"
|
||||
style="overflow-x: auto;"
|
||||
>
|
||||
<NDescriptionsItem label="启动时间">
|
||||
<NIcon :component="TimeOutline" /> {{ formattedStartedAt }}
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="运行时长">
|
||||
<NIcon :component="TimerOutline" /> {{ uptime }}
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="SignalR 服务">
|
||||
<NFlex
|
||||
align="center"
|
||||
size="small"
|
||||
:wrap="false"
|
||||
>
|
||||
<NTag
|
||||
:type="signalRStateType"
|
||||
size="tiny"
|
||||
>
|
||||
{{ signalRStateText }}
|
||||
</NTag>
|
||||
<NEllipsis style="max-width: 150px;">
|
||||
{{ webfetcher.signalRId ?? 'N/A' }}
|
||||
</NEllipsis>
|
||||
</NFlex>
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="弹幕服务器">
|
||||
<NFlex
|
||||
align="center"
|
||||
size="small"
|
||||
:wrap="false"
|
||||
>
|
||||
<NTag
|
||||
:type="danmakuClientStateType"
|
||||
size="tiny"
|
||||
>
|
||||
{{ danmakuClientStateText }}
|
||||
</NTag>
|
||||
<NEllipsis style="max-width: 150px;">
|
||||
{{ webfetcher.danmakuServerUrl ?? 'N/A' }}
|
||||
</NEllipsis> <!-- Assuming this is exposed -->
|
||||
</NFlex>
|
||||
</NDescriptionsItem>
|
||||
<NDescriptionsItem label="网络状态">
|
||||
<NFlex
|
||||
align="center"
|
||||
size="small"
|
||||
>
|
||||
<NTag
|
||||
:type="networkStatus === 'online' ? 'success' : 'error'"
|
||||
size="tiny"
|
||||
>
|
||||
<template #icon>
|
||||
<NIcon :component="WifiOutline" />
|
||||
</template>
|
||||
{{ networkStatus === 'online' ? '在线' : '离线' }}
|
||||
</NTag>
|
||||
</NFlex>
|
||||
</NDescriptionsItem>
|
||||
</NDescriptions>
|
||||
</NCard>
|
||||
|
||||
<!-- Live Stream Info -->
|
||||
<NCard
|
||||
v-if="settings.settings.useDanmakuClientType === 'openlive'"
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
// 引入子组件
|
||||
import WindowBar from './WindowBar.vue';
|
||||
import { initAll, OnClientUnmounted } from './data/initialize';
|
||||
import { CloudArchive24Filled, Settings24Filled } from '@vicons/fluent';
|
||||
import { Chat24Filled, CloudArchive24Filled, FlashAuto24Filled, Settings24Filled } from '@vicons/fluent';
|
||||
import { isTauri } from '@/data/constants';
|
||||
import { useDanmakuWindow } from './store/useDanmakuWindow';
|
||||
|
||||
@@ -106,11 +106,17 @@ import { useDanmakuWindow } from './store/useDanmakuWindow';
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(RouterLink, { to: { name: 'client-danmaku-window-manage' } }, () => '弹幕机管理'),
|
||||
h(RouterLink, { to: { name: 'client-danmaku-window-manage' } }, () => '弹幕机'),
|
||||
key: 'danmaku-window-manage',
|
||||
icon: () => h(Settings24Filled),
|
||||
icon: () => h(Chat24Filled),
|
||||
show: danmakuWindow.danmakuWindow != undefined
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(RouterLink, { to: { name: 'client-auto-action-manage' } }, () => '自动操作'),
|
||||
key: 'danmaku-auto-action-manage',
|
||||
icon: () => h(FlashAuto24Filled),
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(RouterLink, { to: { name: 'client-settings' } }, () => '设置'),
|
||||
|
||||
313
src/client/components/autoaction/AutoReplyConfig.vue
Normal file
313
src/client/components/autoaction/AutoReplyConfig.vue
Normal file
@@ -0,0 +1,313 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { NSpace, NDivider, NInputNumber, NCard, NButton, NInput, NCollapse, NCollapseItem, NPopconfirm, NTag } from 'naive-ui';
|
||||
import CommonConfigItems from './CommonConfigItems.vue';
|
||||
import { AutoReplyConfig } from '@/client/store/useAutoAction';
|
||||
|
||||
const props = defineProps({
|
||||
config: {
|
||||
type: Object as () => AutoReplyConfig,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
// 新增规则表单数据
|
||||
const newRule = ref({
|
||||
keywords: [] as string[],
|
||||
replies: [] as string[],
|
||||
blockwords: [] as string[]
|
||||
});
|
||||
|
||||
// 临时输入字段
|
||||
const tempKeyword = ref('');
|
||||
const tempReply = ref('');
|
||||
const tempBlockword = ref('');
|
||||
|
||||
function addKeyword() {
|
||||
if (tempKeyword.value.trim() && !newRule.value.keywords.includes(tempKeyword.value.trim())) {
|
||||
newRule.value.keywords.push(tempKeyword.value.trim());
|
||||
tempKeyword.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function addReply() {
|
||||
if (tempReply.value.trim() && !newRule.value.replies.includes(tempReply.value.trim())) {
|
||||
newRule.value.replies.push(tempReply.value.trim());
|
||||
tempReply.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function addBlockword() {
|
||||
if (tempBlockword.value.trim() && !newRule.value.blockwords.includes(tempBlockword.value.trim())) {
|
||||
newRule.value.blockwords.push(tempBlockword.value.trim());
|
||||
tempBlockword.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function addRule() {
|
||||
if (newRule.value.keywords.length > 0 && newRule.value.replies.length > 0) {
|
||||
props.config.rules.push({
|
||||
keywords: [...newRule.value.keywords],
|
||||
replies: [...newRule.value.replies],
|
||||
blockwords: [...newRule.value.blockwords]
|
||||
});
|
||||
|
||||
// 重置表单
|
||||
newRule.value = {
|
||||
keywords: [],
|
||||
replies: [],
|
||||
blockwords: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function removeRule(index: number) {
|
||||
props.config.rules.splice(index, 1);
|
||||
}
|
||||
|
||||
function removeKeyword(index: number) {
|
||||
newRule.value.keywords.splice(index, 1);
|
||||
}
|
||||
|
||||
function removeReply(index: number) {
|
||||
newRule.value.replies.splice(index, 1);
|
||||
}
|
||||
|
||||
function removeBlockword(index: number) {
|
||||
newRule.value.blockwords.splice(index, 1);
|
||||
}
|
||||
|
||||
function removeRuleKeyword(ruleIndex: number, keywordIndex: number) {
|
||||
props.config.rules[ruleIndex].keywords.splice(keywordIndex, 1);
|
||||
}
|
||||
|
||||
function removeRuleReply(ruleIndex: number, replyIndex: number) {
|
||||
props.config.rules[ruleIndex].replies.splice(replyIndex, 1);
|
||||
}
|
||||
|
||||
function removeRuleBlockword(ruleIndex: number, blockwordIndex: number) {
|
||||
props.config.rules[ruleIndex].blockwords.splice(blockwordIndex, 1);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auto-reply-config">
|
||||
<CommonConfigItems
|
||||
:config="config"
|
||||
:show-live-only="true"
|
||||
:show-delay="false"
|
||||
:show-user-filter="true"
|
||||
:show-tian-xuan="false"
|
||||
/>
|
||||
|
||||
<NDivider title-placement="left">
|
||||
自动回复设置
|
||||
</NDivider>
|
||||
|
||||
<NSpace
|
||||
vertical
|
||||
size="medium"
|
||||
>
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>冷却时间 (秒):</span>
|
||||
<NInputNumber
|
||||
v-model:value="config.cooldownSeconds"
|
||||
:min="0"
|
||||
:max="300"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<NCard
|
||||
title="规则列表"
|
||||
size="small"
|
||||
>
|
||||
<NCollapse>
|
||||
<NCollapseItem
|
||||
v-for="(rule, ruleIndex) in config.rules"
|
||||
:key="ruleIndex"
|
||||
:title="`规则 ${ruleIndex + 1}: ${rule.keywords.join(', ')}`"
|
||||
>
|
||||
<NSpace vertical>
|
||||
<NSpace vertical>
|
||||
<div class="rule-section-title">
|
||||
触发关键词:
|
||||
</div>
|
||||
<NSpace>
|
||||
<NTag
|
||||
v-for="(keyword, keywordIndex) in rule.keywords"
|
||||
:key="keywordIndex"
|
||||
closable
|
||||
@close="removeRuleKeyword(ruleIndex, keywordIndex)"
|
||||
>
|
||||
{{ keyword }}
|
||||
</NTag>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<NSpace vertical>
|
||||
<div class="rule-section-title">
|
||||
回复内容:
|
||||
</div>
|
||||
<NSpace>
|
||||
<NTag
|
||||
v-for="(reply, replyIndex) in rule.replies"
|
||||
:key="replyIndex"
|
||||
closable
|
||||
@close="removeRuleReply(ruleIndex, replyIndex)"
|
||||
>
|
||||
{{ reply }}
|
||||
</NTag>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
v-if="rule.blockwords.length > 0"
|
||||
vertical
|
||||
>
|
||||
<div class="rule-section-title">
|
||||
屏蔽词:
|
||||
</div>
|
||||
<NSpace>
|
||||
<NTag
|
||||
v-for="(blockword, blockwordIndex) in rule.blockwords"
|
||||
:key="blockwordIndex"
|
||||
closable
|
||||
type="warning"
|
||||
@close="removeRuleBlockword(ruleIndex, blockwordIndex)"
|
||||
>
|
||||
{{ blockword }}
|
||||
</NTag>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<NPopconfirm @positive-click="removeRule(ruleIndex)">
|
||||
<template #trigger>
|
||||
<NButton
|
||||
size="small"
|
||||
type="error"
|
||||
>
|
||||
删除规则
|
||||
</NButton>
|
||||
</template>
|
||||
确定要删除此规则吗?
|
||||
</NPopconfirm>
|
||||
</NSpace>
|
||||
</NCollapseItem>
|
||||
</NCollapse>
|
||||
</NCard>
|
||||
|
||||
<NCard
|
||||
title="添加新规则"
|
||||
size="small"
|
||||
>
|
||||
<NSpace vertical>
|
||||
<NSpace vertical>
|
||||
<div class="rule-section-title">
|
||||
触发关键词:
|
||||
</div>
|
||||
<NSpace align="center">
|
||||
<NInput
|
||||
v-model:value="tempKeyword"
|
||||
placeholder="输入关键词"
|
||||
@keyup.enter="addKeyword"
|
||||
/>
|
||||
<NButton @click="addKeyword">
|
||||
添加
|
||||
</NButton>
|
||||
</NSpace>
|
||||
<NSpace>
|
||||
<NTag
|
||||
v-for="(keyword, index) in newRule.keywords"
|
||||
:key="index"
|
||||
closable
|
||||
@close="removeKeyword(index)"
|
||||
>
|
||||
{{ keyword }}
|
||||
</NTag>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<NSpace vertical>
|
||||
<div class="rule-section-title">
|
||||
回复内容: <span class="hint">(可以使用 {{ '\{\{ user.name \}\}' }} 作为用户名变量)</span>
|
||||
</div>
|
||||
<NSpace align="center">
|
||||
<NInput
|
||||
v-model:value="tempReply"
|
||||
placeholder="输入回复内容"
|
||||
@keyup.enter="addReply"
|
||||
/>
|
||||
<NButton @click="addReply">
|
||||
添加
|
||||
</NButton>
|
||||
</NSpace>
|
||||
<NSpace>
|
||||
<NTag
|
||||
v-for="(reply, index) in newRule.replies"
|
||||
:key="index"
|
||||
closable
|
||||
@close="removeReply(index)"
|
||||
>
|
||||
{{ reply }}
|
||||
</NTag>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<NSpace vertical>
|
||||
<div class="rule-section-title">
|
||||
屏蔽词: <span class="hint">(可选,当弹幕中包含屏蔽词时不触发)</span>
|
||||
</div>
|
||||
<NSpace align="center">
|
||||
<NInput
|
||||
v-model:value="tempBlockword"
|
||||
placeholder="输入屏蔽词"
|
||||
@keyup.enter="addBlockword"
|
||||
/>
|
||||
<NButton @click="addBlockword">
|
||||
添加
|
||||
</NButton>
|
||||
</NSpace>
|
||||
<NSpace>
|
||||
<NTag
|
||||
v-for="(blockword, index) in newRule.blockwords"
|
||||
:key="index"
|
||||
closable
|
||||
type="warning"
|
||||
@close="removeBlockword(index)"
|
||||
>
|
||||
{{ blockword }}
|
||||
</NTag>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<NButton
|
||||
type="primary"
|
||||
block
|
||||
:disabled="newRule.keywords.length === 0 || newRule.replies.length === 0"
|
||||
@click="addRule"
|
||||
>
|
||||
保存规则
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
</NSpace>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rule-section-title {
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-weight: normal;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
120
src/client/components/autoaction/CommonConfigItems.vue
Normal file
120
src/client/components/autoaction/CommonConfigItems.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { NSpace, NSwitch, NInputNumber, NSelect, NCheckbox, NDivider } from 'naive-ui';
|
||||
|
||||
defineProps({
|
||||
config: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
showLiveOnly: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showDelay: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showUserFilter: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showTianXuan: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="common-config-section">
|
||||
<NSpace
|
||||
vertical
|
||||
size="medium"
|
||||
>
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>启用功能:</span>
|
||||
<NSwitch v-model:value="config.enabled" />
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
v-if="showLiveOnly"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>仅直播中开启:</span>
|
||||
<NSwitch v-model:value="config.onlyDuringLive" />
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
v-if="showDelay"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>延迟时间 (秒):</span>
|
||||
<NInputNumber
|
||||
v-model:value="config.delaySeconds"
|
||||
:min="0"
|
||||
:max="300"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
v-if="showTianXuan"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>屏蔽天选时刻:</span>
|
||||
<NSwitch v-model:value="config.ignoreTianXuan" />
|
||||
</NSpace>
|
||||
|
||||
<template v-if="showUserFilter">
|
||||
<NDivider title-placement="left">
|
||||
用户过滤设置
|
||||
</NDivider>
|
||||
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>启用用户过滤:</span>
|
||||
<NSwitch v-model:value="config.userFilterEnabled" />
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
v-if="config.userFilterEnabled"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>要求本房间勋章:</span>
|
||||
<NSwitch v-model:value="config.requireMedal" />
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
v-if="config.userFilterEnabled"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>要求任意舰长:</span>
|
||||
<NSwitch v-model:value="config.requireCaptain" />
|
||||
</NSpace>
|
||||
</template>
|
||||
</NSpace>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.common-config-section {
|
||||
padding: 16px 0;
|
||||
}
|
||||
</style>
|
||||
59
src/client/components/autoaction/EntryWelcomeConfig.vue
Normal file
59
src/client/components/autoaction/EntryWelcomeConfig.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { NSpace, NDivider, NInputNumber } from 'naive-ui';
|
||||
import CommonConfigItems from './CommonConfigItems.vue';
|
||||
import TemplateEditor from './TemplateEditor.vue';
|
||||
import { EntryWelcomeConfig } from '@/client/store/useAutoAction';
|
||||
|
||||
const props = defineProps({
|
||||
config: {
|
||||
type: Object as () => EntryWelcomeConfig,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const placeholders = [
|
||||
{ name: '{{user.name}}', description: '被欢迎的用户名或用户列表' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="entry-welcome-config">
|
||||
<CommonConfigItems
|
||||
:config="config"
|
||||
:show-live-only="true"
|
||||
:show-delay="true"
|
||||
:show-user-filter="true"
|
||||
:show-tian-xuan="true"
|
||||
/>
|
||||
|
||||
<NDivider title-placement="left">
|
||||
入场欢迎设置
|
||||
</NDivider>
|
||||
|
||||
<NSpace
|
||||
vertical
|
||||
size="medium"
|
||||
>
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>每次欢迎最大用户数:</span>
|
||||
<NInputNumber
|
||||
v-model:value="config.maxUsersPerMsg"
|
||||
:min="1"
|
||||
:max="20"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<TemplateEditor
|
||||
:templates="config.templates"
|
||||
title="欢迎模板"
|
||||
description="可以使用变量来个性化欢迎内容"
|
||||
:placeholders="placeholders"
|
||||
/>
|
||||
</NSpace>
|
||||
</div>
|
||||
</template>
|
||||
59
src/client/components/autoaction/FollowThankConfig.vue
Normal file
59
src/client/components/autoaction/FollowThankConfig.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { NSpace, NDivider, NInputNumber } from 'naive-ui';
|
||||
import CommonConfigItems from './CommonConfigItems.vue';
|
||||
import TemplateEditor from './TemplateEditor.vue';
|
||||
import { FollowThankConfig } from '@/client/store/useAutoAction';
|
||||
|
||||
const props = defineProps({
|
||||
config: {
|
||||
type: Object as () => FollowThankConfig,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const placeholders = [
|
||||
{ name: '{{user.name}}', description: '被感谢的用户名或用户列表' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="follow-thank-config">
|
||||
<CommonConfigItems
|
||||
:config="config"
|
||||
:show-live-only="true"
|
||||
:show-delay="true"
|
||||
:show-user-filter="false"
|
||||
:show-tian-xuan="true"
|
||||
/>
|
||||
|
||||
<NDivider title-placement="left">
|
||||
关注感谢设置
|
||||
</NDivider>
|
||||
|
||||
<NSpace
|
||||
vertical
|
||||
size="medium"
|
||||
>
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>每次感谢最大用户数:</span>
|
||||
<NInputNumber
|
||||
v-model:value="config.maxUsersPerMsg"
|
||||
:min="1"
|
||||
:max="20"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<TemplateEditor
|
||||
:templates="config.templates"
|
||||
title="感谢模板"
|
||||
description="可以使用变量来个性化感谢内容"
|
||||
:placeholders="placeholders"
|
||||
/>
|
||||
</NSpace>
|
||||
</div>
|
||||
</template>
|
||||
161
src/client/components/autoaction/GiftThankConfig.vue
Normal file
161
src/client/components/autoaction/GiftThankConfig.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import { NCard, NSpace, NDivider, NSelect, NInputNumber, NSwitch, NRadioGroup, NRadio } from 'naive-ui';
|
||||
import CommonConfigItems from './CommonConfigItems.vue';
|
||||
import TemplateEditor from './TemplateEditor.vue';
|
||||
import { GiftThankConfig } from '@/client/store/useAutoAction';
|
||||
|
||||
const props = defineProps({
|
||||
config: {
|
||||
type: Object as () => GiftThankConfig,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const filterModeOptions = [
|
||||
{ label: '不过滤', value: 'none' },
|
||||
{ label: '礼物黑名单', value: 'blacklist' },
|
||||
{ label: '礼物白名单', value: 'whitelist' },
|
||||
{ label: '最低价值', value: 'value' },
|
||||
{ label: '过滤免费礼物', value: 'free' }
|
||||
];
|
||||
|
||||
const thankModeOptions = [
|
||||
{ label: '单用户单礼物', value: 'singleGift' },
|
||||
{ label: '单用户多礼物', value: 'singleUserMultiGift' },
|
||||
{ label: '多用户多礼物', value: 'multiUserMultiGift' }
|
||||
];
|
||||
|
||||
const placeholders = [
|
||||
{ name: '{{user.name}}', description: '用户名称' },
|
||||
{ name: '{{gift.summary}}', description: '礼物摘要,包含礼物名称和数量' },
|
||||
{ name: '{{gift.totalPrice}}', description: '礼物总价值' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="gift-thank-config">
|
||||
<CommonConfigItems
|
||||
:config="config"
|
||||
:show-live-only="true"
|
||||
:show-delay="true"
|
||||
:show-user-filter="true"
|
||||
:show-tian-xuan="true"
|
||||
/>
|
||||
|
||||
<NDivider title-placement="left">
|
||||
礼物过滤设置
|
||||
</NDivider>
|
||||
|
||||
<NSpace
|
||||
vertical
|
||||
size="medium"
|
||||
>
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>过滤模式:</span>
|
||||
<NSelect
|
||||
v-model:value="config.filterMode"
|
||||
:options="filterModeOptions"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
v-if="config.filterMode === 'value'"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>最低价值 (元):</span>
|
||||
<NInputNumber
|
||||
v-model:value="config.minValue"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<TemplateEditor
|
||||
:templates="config.filterGiftNames"
|
||||
title="礼物名称列表"
|
||||
:description="config.filterMode === 'blacklist' ? '以下礼物将被过滤不触发感谢' : config.filterMode === 'whitelist' ? '只有以下礼物会触发感谢' : '请添加礼物名称'"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<NDivider title-placement="left">
|
||||
感谢设置
|
||||
</NDivider>
|
||||
|
||||
<NSpace
|
||||
vertical
|
||||
size="medium"
|
||||
>
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>感谢模式:</span>
|
||||
<NRadioGroup v-model:value="config.thankMode">
|
||||
<NSpace>
|
||||
<NRadio
|
||||
v-for="option in thankModeOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</NRadio>
|
||||
</NSpace>
|
||||
</NRadioGroup>
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>每次感谢最大用户数:</span>
|
||||
<NInputNumber
|
||||
v-model:value="config.maxUsersPerMsg"
|
||||
:min="1"
|
||||
:max="20"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
v-if="config.thankMode === 'singleUserMultiGift'"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>每用户最大礼物数:</span>
|
||||
<NInputNumber
|
||||
v-model:value="config.maxGiftsPerUser"
|
||||
:min="1"
|
||||
:max="10"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>包含礼物数量:</span>
|
||||
<NSwitch v-model:value="config.includeQuantity" />
|
||||
</NSpace>
|
||||
|
||||
<TemplateEditor
|
||||
:templates="config.templates"
|
||||
title="感谢模板"
|
||||
description="可以使用变量来个性化感谢内容"
|
||||
:placeholders="placeholders"
|
||||
/>
|
||||
</NSpace>
|
||||
</div>
|
||||
</template>
|
||||
223
src/client/components/autoaction/GuardPmConfig.vue
Normal file
223
src/client/components/autoaction/GuardPmConfig.vue
Normal file
@@ -0,0 +1,223 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { NCard, NSpace, NDivider, NInput, NSwitch, NButton, NSelect, NPopconfirm } from 'naive-ui';
|
||||
import CommonConfigItems from './CommonConfigItems.vue';
|
||||
import { GuardPmConfig, GuardLevel } from '@/client/store/useAutoAction';
|
||||
|
||||
const props = defineProps({
|
||||
config: {
|
||||
type: Object as () => GuardPmConfig,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const newCode = ref('');
|
||||
const selectedLevel = ref(GuardLevel.Jianzhang);
|
||||
|
||||
const levelOptions = [
|
||||
{ label: '通用', value: GuardLevel.None },
|
||||
{ label: '舰长', value: GuardLevel.Jianzhang },
|
||||
{ label: '提督', value: GuardLevel.Tidu },
|
||||
{ label: '总督', value: GuardLevel.Zongdu }
|
||||
];
|
||||
|
||||
function getLevelName(level: GuardLevel): string {
|
||||
const opt = levelOptions.find(o => o.value === level);
|
||||
return opt ? opt.label : '未知';
|
||||
}
|
||||
|
||||
function addGiftCode() {
|
||||
if (!newCode.value.trim()) return;
|
||||
|
||||
const level = selectedLevel.value;
|
||||
const levelCodes = props.config.giftCodes.find(gc => gc.level === level);
|
||||
|
||||
if (levelCodes) {
|
||||
levelCodes.codes.push(newCode.value.trim());
|
||||
} else {
|
||||
props.config.giftCodes.push({
|
||||
level: level,
|
||||
codes: [newCode.value.trim()]
|
||||
});
|
||||
}
|
||||
|
||||
newCode.value = '';
|
||||
}
|
||||
|
||||
function removeCode(level: GuardLevel, index: number) {
|
||||
const levelCodes = props.config.giftCodes.find(gc => gc.level === level);
|
||||
if (levelCodes) {
|
||||
levelCodes.codes.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
const placeholders = [
|
||||
{ name: '{{user.name}}', description: '用户名称' },
|
||||
{ name: '{{guard.levelName}}', description: '舰长等级名称' },
|
||||
{ name: '{{guard.giftCode}}', description: '礼品码(礼品码模式下可用)' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="guard-pm-config">
|
||||
<CommonConfigItems
|
||||
:config="config"
|
||||
:show-live-only="true"
|
||||
:show-delay="false"
|
||||
:show-user-filter="false"
|
||||
:show-tian-xuan="false"
|
||||
/>
|
||||
|
||||
<NDivider title-placement="left">
|
||||
私信设置
|
||||
</NDivider>
|
||||
|
||||
<NSpace
|
||||
vertical
|
||||
size="medium"
|
||||
>
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>私信模板:</span>
|
||||
<NInput
|
||||
v-model:value="config.template"
|
||||
placeholder="例如: 感谢 {{user.name}} 成为 {{guard.levelName}}!"
|
||||
style="width: 350px"
|
||||
>
|
||||
<template #prefix>
|
||||
<NPopconfirm placement="bottom">
|
||||
<template #trigger>
|
||||
<NButton
|
||||
quaternary
|
||||
circle
|
||||
size="small"
|
||||
>
|
||||
?
|
||||
</NButton>
|
||||
</template>
|
||||
<div>
|
||||
<div
|
||||
v-for="ph in placeholders"
|
||||
:key="ph.name"
|
||||
>
|
||||
<strong>{{ ph.name }}</strong>: {{ ph.description }}
|
||||
</div>
|
||||
</div>
|
||||
</NPopconfirm>
|
||||
</template>
|
||||
</NInput>
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>发送弹幕确认:</span>
|
||||
<NSwitch v-model:value="config.sendDanmakuConfirm" />
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
v-if="config.sendDanmakuConfirm"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>弹幕确认模板:</span>
|
||||
<NInput
|
||||
v-model:value="config.danmakuTemplate"
|
||||
placeholder="例如: 已私信 {{user.name}} 舰长福利!"
|
||||
style="width: 350px"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>防止重复发送:</span>
|
||||
<NSwitch v-model:value="config.preventRepeat" />
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<NDivider title-placement="left">
|
||||
礼品码模式
|
||||
</NDivider>
|
||||
|
||||
<NSpace
|
||||
vertical
|
||||
size="medium"
|
||||
>
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>启用礼品码模式:</span>
|
||||
<NSwitch v-model:value="config.giftCodeMode" />
|
||||
</NSpace>
|
||||
|
||||
<div v-if="config.giftCodeMode">
|
||||
<NCard
|
||||
title="添加礼品码"
|
||||
size="small"
|
||||
>
|
||||
<NSpace vertical>
|
||||
<NSpace justify="space-between">
|
||||
<NSelect
|
||||
v-model:value="selectedLevel"
|
||||
:options="levelOptions"
|
||||
style="width: 120px"
|
||||
/>
|
||||
<NInput
|
||||
v-model:value="newCode"
|
||||
placeholder="输入礼品码"
|
||||
style="flex: 1"
|
||||
/>
|
||||
<NButton
|
||||
type="primary"
|
||||
@click="addGiftCode"
|
||||
>
|
||||
添加
|
||||
</NButton>
|
||||
</NSpace>
|
||||
|
||||
<NDivider />
|
||||
|
||||
<div
|
||||
v-for="levelData in config.giftCodes"
|
||||
:key="levelData.level"
|
||||
>
|
||||
<NCard
|
||||
v-if="levelData.codes.length > 0"
|
||||
:title="getLevelName(levelData.level) + ' 礼品码'"
|
||||
size="small"
|
||||
>
|
||||
<NSpace
|
||||
v-for="(code, index) in levelData.codes"
|
||||
:key="index"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>{{ code }}</span>
|
||||
<NButton
|
||||
size="small"
|
||||
type="error"
|
||||
quaternary
|
||||
@click="removeCode(levelData.level, index)"
|
||||
>
|
||||
删除
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
</div>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
</div>
|
||||
</NSpace>
|
||||
</div>
|
||||
</template>
|
||||
78
src/client/components/autoaction/ScheduledDanmakuConfig.vue
Normal file
78
src/client/components/autoaction/ScheduledDanmakuConfig.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { NSpace, NDivider, NInputNumber, NRadioGroup, NRadio } from 'naive-ui';
|
||||
import CommonConfigItems from './CommonConfigItems.vue';
|
||||
import TemplateEditor from './TemplateEditor.vue';
|
||||
import { ScheduledDanmakuConfig } from '@/client/store/useAutoAction';
|
||||
|
||||
const props = defineProps({
|
||||
config: {
|
||||
type: Object as () => ScheduledDanmakuConfig,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const modeOptions = [
|
||||
{ label: '随机模式', value: 'random' },
|
||||
{ label: '顺序模式', value: 'sequential' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="scheduled-danmaku-config">
|
||||
<CommonConfigItems
|
||||
:config="config"
|
||||
:show-live-only="true"
|
||||
:show-delay="false"
|
||||
:show-user-filter="false"
|
||||
:show-tian-xuan="false"
|
||||
/>
|
||||
|
||||
<NDivider title-placement="left">
|
||||
定时弹幕设置
|
||||
</NDivider>
|
||||
|
||||
<NSpace
|
||||
vertical
|
||||
size="medium"
|
||||
>
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>发送间隔 (秒):</span>
|
||||
<NInputNumber
|
||||
v-model:value="config.intervalSeconds"
|
||||
:min="60"
|
||||
:max="3600"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</NSpace>
|
||||
|
||||
<NSpace
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>发送模式:</span>
|
||||
<NRadioGroup v-model:value="config.mode">
|
||||
<NSpace>
|
||||
<NRadio
|
||||
v-for="option in modeOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</NRadio>
|
||||
</NSpace>
|
||||
</NRadioGroup>
|
||||
</NSpace>
|
||||
|
||||
<TemplateEditor
|
||||
:templates="config.messages"
|
||||
title="弹幕内容列表"
|
||||
description="每条消息将按照设定的模式定时发送"
|
||||
/>
|
||||
</NSpace>
|
||||
</div>
|
||||
</template>
|
||||
167
src/client/components/autoaction/TemplateEditor.vue
Normal file
167
src/client/components/autoaction/TemplateEditor.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { NInput, NInputNumber, NButton, NSpace, NCard, NDivider, NList, NListItem, NPopconfirm, NTooltip } from 'naive-ui';
|
||||
|
||||
const props = defineProps({
|
||||
templates: {
|
||||
type: Array as () => string[],
|
||||
required: true
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '模板编辑'
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
placeholders: {
|
||||
type: Array as () => { name: string, description: string }[],
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
// 添加默认的弹幕相关占位符
|
||||
const mergedPlaceholders = computed(() => {
|
||||
const defaultPlaceholders = [
|
||||
{ name: '{{danmaku.type}}', description: '事件类型' },
|
||||
{ name: '{{danmaku.uname}}', description: '用户名称' },
|
||||
{ name: '{{danmaku.uface}}', description: '用户头像URL' },
|
||||
{ name: '{{danmaku.uid}}', description: '用户ID(直接连接)' },
|
||||
{ name: '{{danmaku.open_id}}', description: '用户开放平台ID' },
|
||||
{ name: '{{danmaku.msg}}', description: '消息内容' },
|
||||
{ name: '{{danmaku.time}}', description: '时间戳' },
|
||||
{ name: '{{danmaku.num}}', description: '数量' },
|
||||
{ name: '{{danmaku.price}}', description: '价格' },
|
||||
{ name: '{{danmaku.guard_level}}', description: '大航海等级' },
|
||||
{ name: '{{danmaku.fans_medal_level}}', description: '粉丝牌等级' },
|
||||
{ name: '{{danmaku.fans_medal_name}}', description: '粉丝牌名称' },
|
||||
{ name: '{{danmaku.fans_medal_wearing_status}}', description: '是否佩戴粉丝牌' },
|
||||
{ name: '{{danmaku.emoji}}', description: '表情符号' }
|
||||
];
|
||||
|
||||
// 返回自定义占位符和默认占位符,但不合并它们
|
||||
return { custom: props.placeholders, default: defaultPlaceholders };
|
||||
});
|
||||
|
||||
const newTemplate = ref('');
|
||||
|
||||
function addTemplate() {
|
||||
if (newTemplate.value.trim()) {
|
||||
props.templates.push(newTemplate.value.trim());
|
||||
newTemplate.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function removeTemplate(index: number) {
|
||||
props.templates.splice(index, 1);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCard
|
||||
:title="title"
|
||||
size="small"
|
||||
>
|
||||
<template
|
||||
v-if="mergedPlaceholders.custom.length > 0 || mergedPlaceholders.default.length > 0"
|
||||
#header-extra
|
||||
>
|
||||
<NTooltip
|
||||
trigger="hover"
|
||||
placement="top"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton
|
||||
quaternary
|
||||
size="small"
|
||||
>
|
||||
变量说明
|
||||
</NButton>
|
||||
</template>
|
||||
<div style="max-width: 300px">
|
||||
<div
|
||||
v-for="(ph, idx) in mergedPlaceholders.custom"
|
||||
:key="'custom-' + idx"
|
||||
>
|
||||
<strong>{{ ph.name }}</strong>: {{ ph.description }}
|
||||
</div>
|
||||
<NDivider
|
||||
v-if="mergedPlaceholders.custom.length > 0 && mergedPlaceholders.default.length > 0"
|
||||
style="margin: 10px 0;">
|
||||
默认变量
|
||||
</NDivider>
|
||||
<div
|
||||
v-for="(ph, idx) in mergedPlaceholders.default"
|
||||
:key="'default-' + idx"
|
||||
>
|
||||
<strong>{{ ph.name }}</strong>: {{ ph.description }}
|
||||
</div>
|
||||
</div>
|
||||
</NTooltip>
|
||||
</template>
|
||||
|
||||
<p
|
||||
v-if="description"
|
||||
class="template-description"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<NList bordered>
|
||||
<NListItem
|
||||
v-for="(template, index) in templates"
|
||||
:key="index"
|
||||
>
|
||||
<NSpace
|
||||
justify="space-between"
|
||||
align="center"
|
||||
style="width: 100%"
|
||||
>
|
||||
<span>{{ template }}</span>
|
||||
<NPopconfirm @positive-click="removeTemplate(index)">
|
||||
<template #trigger>
|
||||
<NButton
|
||||
size="small"
|
||||
quaternary
|
||||
type="error"
|
||||
>
|
||||
删除
|
||||
</NButton>
|
||||
</template>
|
||||
确定要删除此模板吗?
|
||||
</NPopconfirm>
|
||||
</NSpace>
|
||||
</NListItem>
|
||||
</NList>
|
||||
|
||||
<NDivider />
|
||||
|
||||
<NSpace vertical>
|
||||
<NInput
|
||||
v-model:value="newTemplate"
|
||||
placeholder="输入新模板内容"
|
||||
clearable
|
||||
/>
|
||||
<NButton
|
||||
type="primary"
|
||||
block
|
||||
@click="addTemplate"
|
||||
>
|
||||
添加模板
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.template-description {
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
@@ -136,7 +136,7 @@ const priceText = computed(() => {
|
||||
|
||||
// 获取用户名显示
|
||||
const displayName = computed(() => {
|
||||
return props.item.name || '匿名用户';
|
||||
return props.item.uname || '匿名用户';
|
||||
});
|
||||
|
||||
// 获取消息显示内容
|
||||
|
||||
@@ -19,7 +19,8 @@ import { VehicleShip24Filled } from '@vicons/fluent';
|
||||
showAvatar,
|
||||
guardColor,
|
||||
scColorClass,
|
||||
parsedMessage
|
||||
parsedMessage,
|
||||
medalColor
|
||||
} = danmakuUtils;
|
||||
</script>
|
||||
|
||||
@@ -44,7 +45,7 @@ import { VehicleShip24Filled } from '@vicons/fluent';
|
||||
class="username"
|
||||
:style="{ color: item.type === EventDataTypes.SC ? '#222' : '#fff' }"
|
||||
>
|
||||
{{ item?.name || '匿名用户' }}
|
||||
{{ item?.uname || '匿名用户' }}
|
||||
</span>
|
||||
<!-- 卡片右侧徽章 -->
|
||||
<template v-if="item.type === EventDataTypes.SC">
|
||||
@@ -101,7 +102,7 @@ import { VehicleShip24Filled } from '@vicons/fluent';
|
||||
class="username"
|
||||
:style="{ color: '#fff' }"
|
||||
>
|
||||
{{ item?.name || '匿名用户' }}
|
||||
{{ item?.uname || '匿名用户' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="item.guard_level && item.guard_level > 0"
|
||||
@@ -113,6 +114,18 @@ import { VehicleShip24Filled } from '@vicons/fluent';
|
||||
size="12"
|
||||
/>
|
||||
</span>
|
||||
<!-- 添加粉丝勋章显示 -->
|
||||
<span
|
||||
v-if="setting.showFansMedal && item.fans_medal_wearing_status && item.fans_medal_level > 0"
|
||||
class="fans-medal"
|
||||
:style="{ backgroundColor: medalColor }"
|
||||
>
|
||||
<span class="medal-name">{{ item.fans_medal_name }}</span>
|
||||
<span
|
||||
class="medal-level"
|
||||
:style="{ backgroundColor: `${medalColor}CC` }"
|
||||
>{{ item.fans_medal_level }}</span>
|
||||
</span>
|
||||
<template v-if="item.type === EventDataTypes.Enter">
|
||||
<span class="enter-badge">进入了直播间</span>
|
||||
</template>
|
||||
@@ -264,7 +277,6 @@ import { VehicleShip24Filled } from '@vicons/fluent';
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
min-height: var(--dw-avatar-size);
|
||||
}
|
||||
@@ -388,6 +400,34 @@ import { VehicleShip24Filled } from '@vicons/fluent';
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 粉丝勋章样式 */
|
||||
.fans-medal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 4px;
|
||||
margin-left: 4px;
|
||||
font-size: 0.75em;
|
||||
height: 16px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.medal-name {
|
||||
padding: 0 3px;
|
||||
color: #fff;
|
||||
max-width: 40px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.medal-level {
|
||||
padding: 0 3px;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.enter-badge {
|
||||
color: #67C23A;
|
||||
font-size: 0.85em;
|
||||
|
||||
@@ -3,6 +3,31 @@ import { DanmakuWindowSettings } from '../../store/useDanmakuWindow';
|
||||
import { computed, ComputedRef } from 'vue';
|
||||
import { GetGuardColor } from '@/Utils';
|
||||
|
||||
// 粉丝勋章等级对应的颜色
|
||||
export const MEDAL_LEVEL_COLORS: { [key: number]: string } = {
|
||||
1: '#68a49a', 2: '#5b9a8f', 3: '#539288', 4: '#4e8a80',
|
||||
5: '#607ea0', 6: '#54708f', 7: '#4e6887', 8: '#49617e',
|
||||
9: '#8d7a9b', 10: '#816d8f', 11: '#776385', 12: '#6e5a7c',
|
||||
13: '#c06d80', 14: '#b66174', 15: '#ac586a', 16: '#a34f61',
|
||||
17: '#caa44a', 18: '#bf973e', 19: '#b68c35', 20: '#ae812f',
|
||||
21: '#347368', 22: '#2e685e', 23: '#285e55', 24: '#25564e',
|
||||
25: '#354b86', 26: '#2e4179', 27: '#293a6f', 28: '#243466',
|
||||
29: '#624180', 30: '#573873', 31: '#4f3168', 32: '#482b5f',
|
||||
33: '#a23e54', 34: '#92364a', 35: '#843042', 36: '#772a3b',
|
||||
37: '#f38b3c', 38: '#e87b2e', 39: '#de6e23', 40: '#d5621a'
|
||||
};
|
||||
|
||||
// 获取粉丝勋章颜色
|
||||
export function getMedalColor(level: number): string {
|
||||
// 处理超过40级的情况,颜色循环使用
|
||||
if (level > 40) {
|
||||
level = 40;
|
||||
}
|
||||
|
||||
// 如果找不到对应等级颜色或者等级小于1,返回默认颜色
|
||||
return MEDAL_LEVEL_COLORS[level] || '#999999';
|
||||
}
|
||||
|
||||
export interface BaseDanmakuItemProps {
|
||||
item: EventModel & { randomId: string; isNew?: boolean; disappearAt?: number; };
|
||||
setting: DanmakuWindowSettings;
|
||||
@@ -129,7 +154,7 @@ export function useDanmakuUtils(
|
||||
|
||||
// 获取用户名显示
|
||||
const displayName = computed(() => {
|
||||
return props.item.name || '匿名用户';
|
||||
return props.item.uname || '匿名用户';
|
||||
});
|
||||
|
||||
// 获取消息显示内容
|
||||
@@ -164,6 +189,14 @@ export function useDanmakuUtils(
|
||||
return undefined; // 普通消息使用默认颜色
|
||||
});
|
||||
|
||||
// 计算粉丝勋章颜色
|
||||
const medalColor = computed(() => {
|
||||
if (props.item.fans_medal_level && props.item.fans_medal_level > 0) {
|
||||
return getMedalColor(props.item.fans_medal_level);
|
||||
}
|
||||
return '#999999'; // 默认颜色
|
||||
});
|
||||
|
||||
return {
|
||||
scColorClass,
|
||||
typeClass,
|
||||
@@ -175,7 +208,8 @@ export function useDanmakuUtils(
|
||||
priceText,
|
||||
displayName,
|
||||
displayContent,
|
||||
textModeColor
|
||||
textModeColor,
|
||||
medalColor // 添加粉丝勋章颜色计算属性
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ export async function initAll(isOnBoot: boolean) {
|
||||
await useWindow.getEmojiData();
|
||||
}
|
||||
if (await w.isVisible()) {
|
||||
//useWindow.isDanmakuWindowOpen = true;
|
||||
useWindow.isDanmakuWindowOpen = true;
|
||||
|
||||
console.log('弹幕窗口已打开');
|
||||
}
|
||||
|
||||
642
src/client/store/useAutoAction.ts
Normal file
642
src/client/store/useAutoAction.ts
Normal file
@@ -0,0 +1,642 @@
|
||||
import { ref, reactive, watch, computed, onUnmounted } from 'vue';
|
||||
import { defineStore, acceptHMRUpdate } from 'pinia';
|
||||
import { EventModel, EventDataTypes, GuardLevel } from '@/api/api-models';
|
||||
import { useDanmakuClient } from '@/store/useDanmakuClient';
|
||||
import { useBiliFunction } from './useBiliFunction';
|
||||
import { useAccount } from '@/api/account';
|
||||
import { useStorage } from '@vueuse/core'
|
||||
|
||||
// --- 配置类型定义 ---
|
||||
export interface GiftThankConfig {
|
||||
enabled: boolean;
|
||||
delaySeconds: number; // 延迟感谢秒数 (0表示立即)
|
||||
templates: string[]; // 感谢弹幕模板
|
||||
filterMode: 'none' | 'blacklist' | 'whitelist' | 'value' | 'free'; // 过滤模式
|
||||
filterGiftNames: string[]; // 黑/白名单礼物名称
|
||||
minValue: number; // 最低价值 (用于 value 模式)
|
||||
ignoreTianXuan: boolean; // 屏蔽天选时刻礼物
|
||||
thankMode: 'singleGift' | 'singleUserMultiGift' | 'multiUserMultiGift'; // 感谢模式
|
||||
maxUsersPerMsg: number; // 每次感谢最大用户数
|
||||
maxGiftsPerUser: number; // 每用户最大礼物数 (用于 singleUserMultiGift)
|
||||
includeQuantity: boolean; // 是否包含礼物数量
|
||||
userFilterEnabled: boolean; // 是否启用用户过滤
|
||||
requireMedal: boolean; // 要求本房间勋章
|
||||
requireCaptain: boolean; // 要求任意舰长
|
||||
onlyDuringLive: boolean; // 仅直播中开启
|
||||
}
|
||||
|
||||
export interface GuardPmConfig {
|
||||
enabled: boolean;
|
||||
template: string; // 私信模板
|
||||
sendDanmakuConfirm: boolean; // 是否发送弹幕确认
|
||||
danmakuTemplate: string; // 弹幕确认模板
|
||||
preventRepeat: boolean; // 防止重复发送 (需要本地存储)
|
||||
giftCodeMode: boolean; // 礼品码模式
|
||||
giftCodes: { level: GuardLevel; codes: string[]; }[]; // 分等级礼品码
|
||||
onlyDuringLive: boolean; // 仅直播中开启
|
||||
}
|
||||
|
||||
export interface FollowThankConfig {
|
||||
enabled: boolean;
|
||||
delaySeconds: number;
|
||||
templates: string[];
|
||||
maxUsersPerMsg: number;
|
||||
ignoreTianXuan: boolean;
|
||||
onlyDuringLive: boolean;
|
||||
}
|
||||
|
||||
export interface EntryWelcomeConfig {
|
||||
enabled: boolean;
|
||||
delaySeconds: number;
|
||||
templates: string[];
|
||||
maxUsersPerMsg: number;
|
||||
ignoreTianXuan: boolean;
|
||||
userFilterEnabled: boolean;
|
||||
requireMedal: boolean;
|
||||
requireCaptain: boolean;
|
||||
onlyDuringLive: boolean;
|
||||
}
|
||||
|
||||
export interface ScheduledDanmakuConfig {
|
||||
enabled: boolean;
|
||||
intervalSeconds: number;
|
||||
messages: string[];
|
||||
mode: 'random' | 'sequential';
|
||||
onlyDuringLive: boolean;
|
||||
}
|
||||
|
||||
export interface AutoReplyConfig {
|
||||
enabled: boolean;
|
||||
cooldownSeconds: number;
|
||||
rules: { keywords: string[]; replies: string[]; blockwords: string[]; }[];
|
||||
userFilterEnabled: boolean;
|
||||
requireMedal: boolean;
|
||||
requireCaptain: boolean;
|
||||
onlyDuringLive: boolean;
|
||||
}
|
||||
|
||||
// --- 聚合数据结构 ---
|
||||
interface AggregatedGift {
|
||||
uid: number;
|
||||
name: string; // 用户名
|
||||
gifts: { [giftName: string]: { count: number; price: number; }; }; // 礼物名 -> {数量, 单价}
|
||||
totalPrice: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface AggregatedUser {
|
||||
uid: number;
|
||||
name: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
|
||||
export const useAutoAction = defineStore('autoAction', () => {
|
||||
const danmakuClient = useDanmakuClient();
|
||||
const biliFunc = useBiliFunction();
|
||||
const account = useAccount(); // 用于获取房间ID和直播状态
|
||||
|
||||
// --- 状态定义 ---
|
||||
const giftThankConfig = useStorage<GiftThankConfig>(
|
||||
'autoAction.giftThankConfig',
|
||||
{
|
||||
enabled: false,
|
||||
delaySeconds: 5,
|
||||
templates: ['感谢 {{user.name}} 赠送的 {{gift.summary}}!'],
|
||||
filterMode: 'none',
|
||||
filterGiftNames: [],
|
||||
minValue: 0,
|
||||
ignoreTianXuan: true,
|
||||
thankMode: 'singleUserMultiGift',
|
||||
maxUsersPerMsg: 3,
|
||||
maxGiftsPerUser: 3,
|
||||
includeQuantity: true,
|
||||
userFilterEnabled: false,
|
||||
requireMedal: false,
|
||||
requireCaptain: false,
|
||||
onlyDuringLive: true
|
||||
}
|
||||
)
|
||||
|
||||
const guardPmConfig = useStorage<GuardPmConfig>(
|
||||
'autoAction.guardPmConfig',
|
||||
{
|
||||
enabled: false,
|
||||
template: '感谢 {{user.name}} 成为 {{guard.levelName}}!欢迎加入!',
|
||||
sendDanmakuConfirm: false,
|
||||
danmakuTemplate: '已私信 {{user.name}} 舰长福利!',
|
||||
preventRepeat: true,
|
||||
giftCodeMode: false,
|
||||
giftCodes: [],
|
||||
onlyDuringLive: true
|
||||
}
|
||||
)
|
||||
|
||||
const followThankConfig = useStorage<FollowThankConfig>(
|
||||
'autoAction.followThankConfig',
|
||||
{
|
||||
enabled: false,
|
||||
delaySeconds: 10,
|
||||
templates: ['感谢 {{user.name}} 的关注!'],
|
||||
maxUsersPerMsg: 5,
|
||||
ignoreTianXuan: true,
|
||||
onlyDuringLive: true
|
||||
}
|
||||
)
|
||||
|
||||
const entryWelcomeConfig = useStorage<EntryWelcomeConfig>(
|
||||
'autoAction.entryWelcomeConfig',
|
||||
{
|
||||
enabled: false,
|
||||
delaySeconds: 15,
|
||||
templates: ['欢迎 {{user.name}} 进入直播间!'],
|
||||
maxUsersPerMsg: 5,
|
||||
ignoreTianXuan: true,
|
||||
userFilterEnabled: false,
|
||||
requireMedal: false,
|
||||
requireCaptain: false,
|
||||
onlyDuringLive: true
|
||||
}
|
||||
)
|
||||
|
||||
const scheduledDanmakuConfig = useStorage<ScheduledDanmakuConfig>(
|
||||
'autoAction.scheduledDanmakuConfig',
|
||||
{
|
||||
enabled: false,
|
||||
intervalSeconds: 300,
|
||||
messages: ['点点关注不迷路~'],
|
||||
mode: 'random',
|
||||
onlyDuringLive: true
|
||||
}
|
||||
)
|
||||
|
||||
const autoReplyConfig = useStorage<AutoReplyConfig>(
|
||||
'autoAction.autoReplyConfig',
|
||||
{
|
||||
enabled: false,
|
||||
cooldownSeconds: 5,
|
||||
rules: [],
|
||||
userFilterEnabled: false,
|
||||
requireMedal: false,
|
||||
requireCaptain: false,
|
||||
onlyDuringLive: true
|
||||
}
|
||||
)
|
||||
|
||||
// --- 运行时数据 ---
|
||||
const aggregatedGifts = ref<AggregatedGift[]>([]); // 聚合的礼物信息
|
||||
const aggregatedFollows = ref<AggregatedUser[]>([]); // 聚合的关注用户
|
||||
const aggregatedEntries = ref<AggregatedUser[]>([]); // 聚合的入场用户
|
||||
const sentGuardPms = useStorage<Set<number>>('autoAction.sentGuardPms', new Set()); // 已发送私信的舰长UID
|
||||
const giftThankTimer = ref<NodeJS.Timeout | null>(null);
|
||||
const followThankTimer = ref<NodeJS.Timeout | null>(null);
|
||||
const entryWelcomeTimer = ref<NodeJS.Timeout | null>(null);
|
||||
const scheduledDanmakuTimer = ref<NodeJS.Timeout | null>(null);
|
||||
const lastReplyTimestamps = ref<{ [keyword: string]: number; }>({}); // 自动回复冷却计时
|
||||
const currentScheduledIndex = ref(0); // 定时弹幕顺序模式索引
|
||||
const isTianXuanActive = ref(false); // 天选时刻状态
|
||||
|
||||
// --- Helper Functions ---
|
||||
const isLive = computed(() => account.value.streamerInfo?.isStreaming ?? false); // 获取直播状态
|
||||
const roomId = computed(() => account.value.streamerInfo?.roomId); // 获取房间ID
|
||||
|
||||
// 检查是否应处理事件 (直播状态过滤)
|
||||
function shouldProcess(config: { enabled: boolean; onlyDuringLive: boolean; }): boolean {
|
||||
if (!config.enabled) return false;
|
||||
return !config.onlyDuringLive || isLive.value;
|
||||
}
|
||||
|
||||
// 检查用户过滤
|
||||
function checkUserFilter(config: { userFilterEnabled: boolean; requireMedal: boolean; requireCaptain: boolean; }, event: EventModel): boolean {
|
||||
if (!config.userFilterEnabled) return true;
|
||||
if (config.requireMedal && !event.fans_medal_wearing_status) return false;
|
||||
if (config.requireCaptain && event.guard_level === GuardLevel.None) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// 获取随机模板
|
||||
function getRandomTemplate(templates: string[]): string {
|
||||
if (!templates || templates.length === 0) return '';
|
||||
return templates[Math.floor(Math.random() * templates.length)];
|
||||
}
|
||||
|
||||
// Helper to get nested property value
|
||||
function getNestedValue(obj: Record<string, any>, path: string): any {
|
||||
return path.split('.').reduce((o, k) => (o && typeof o === 'object' && k in o) ? o[k] : undefined, obj);
|
||||
}
|
||||
|
||||
// 格式化消息 (支持 {{object.property}} )
|
||||
function formatMessage(template: string, params: Record<string, any>): string {
|
||||
return template.replace(/\{\{\s*([^}\s]+)\s*\}\}/g, (match, path) => {
|
||||
const value = getNestedValue(params, path);
|
||||
return value !== undefined ? String(value) : match;
|
||||
});
|
||||
}
|
||||
|
||||
// 检查是否处于天选时刻
|
||||
function checkTianXuanStatus() {
|
||||
if (!roomId.value) return;
|
||||
// 这里可以调用API检查天选时刻状态
|
||||
// 示例实现,实际应该调用B站API
|
||||
biliFunc.checkRoomTianXuanStatus(roomId.value).then(active => {
|
||||
isTianXuanActive.value = active;
|
||||
});
|
||||
}
|
||||
|
||||
// 每5分钟更新一次天选状态
|
||||
const tianXuanTimer = setInterval(checkTianXuanStatus, 5 * 60 * 1000);
|
||||
|
||||
// 清理所有计时器
|
||||
function clearAllTimers() {
|
||||
[giftThankTimer, followThankTimer, entryWelcomeTimer, scheduledDanmakuTimer].forEach(timer => {
|
||||
if (timer.value) clearTimeout(timer.value);
|
||||
});
|
||||
clearInterval(tianXuanTimer);
|
||||
}
|
||||
|
||||
// --- 事件处理 ---
|
||||
|
||||
// 处理礼物事件
|
||||
function onGift(event: EventModel) {
|
||||
if (!shouldProcess(giftThankConfig.value) || !roomId.value) return;
|
||||
if (giftThankConfig.value.ignoreTianXuan && isTianXuanActive.value) return;
|
||||
if (!checkUserFilter(giftThankConfig.value, event)) return;
|
||||
|
||||
// 礼物过滤逻辑
|
||||
const giftName = event.uname;
|
||||
const giftPrice = event.price / 1000; // B站价格单位通常是 1/1000 元
|
||||
const giftCount = event.num;
|
||||
|
||||
switch (giftThankConfig.value.filterMode) {
|
||||
case 'blacklist':
|
||||
if (giftThankConfig.value.filterGiftNames.includes(giftName)) return;
|
||||
break;
|
||||
case 'whitelist':
|
||||
if (!giftThankConfig.value.filterGiftNames.includes(giftName)) return;
|
||||
break;
|
||||
case 'value':
|
||||
if (giftPrice < giftThankConfig.value.minValue) return;
|
||||
break;
|
||||
case 'free':
|
||||
if (giftPrice === 0) return; // 免费礼物价格为0
|
||||
break;
|
||||
}
|
||||
|
||||
// 添加到聚合列表
|
||||
let userGift = aggregatedGifts.value.find(g => g.uid === event.uid);
|
||||
if (!userGift) {
|
||||
userGift = { uid: event.uid, name: event.uname, gifts: {}, totalPrice: 0, timestamp: Date.now() };
|
||||
aggregatedGifts.value.push(userGift);
|
||||
}
|
||||
if (!userGift.gifts[giftName]) {
|
||||
userGift.gifts[giftName] = { count: 0, price: giftPrice };
|
||||
}
|
||||
userGift.gifts[giftName].count += giftCount;
|
||||
userGift.totalPrice += giftPrice * giftCount;
|
||||
userGift.timestamp = Date.now(); // 更新时间戳
|
||||
|
||||
// 重置或启动延迟计时器
|
||||
if (giftThankTimer.value) clearTimeout(giftThankTimer.value);
|
||||
if (giftThankConfig.value.delaySeconds > 0) {
|
||||
giftThankTimer.value = setTimeout(sendGiftThankYou, giftThankConfig.value.delaySeconds * 1000);
|
||||
} else {
|
||||
sendGiftThankYou(); // 立即发送
|
||||
}
|
||||
}
|
||||
|
||||
// 发送礼物感谢
|
||||
function sendGiftThankYou() {
|
||||
if (!roomId.value || aggregatedGifts.value.length === 0) return;
|
||||
|
||||
const usersToThank = aggregatedGifts.value.slice(0, giftThankConfig.value.maxUsersPerMsg);
|
||||
aggregatedGifts.value = aggregatedGifts.value.slice(giftThankConfig.value.maxUsersPerMsg); // 移除已处理的用户
|
||||
|
||||
// 根据感谢模式构建弹幕内容
|
||||
let messages: string[] = [];
|
||||
const template = getRandomTemplate(giftThankConfig.value.templates);
|
||||
if (!template) return;
|
||||
|
||||
usersToThank.forEach(user => {
|
||||
const topGifts = Object.entries(user.gifts)
|
||||
.sort(([, a], [, b]) => b.price * b.count - a.price * a.count) // 按总价值排序
|
||||
.slice(0, giftThankConfig.value.maxGiftsPerUser);
|
||||
|
||||
const giftStrings = topGifts.map(([name, data]) =>
|
||||
giftThankConfig.value.includeQuantity ? `${name}x${data.count}` : name
|
||||
);
|
||||
|
||||
if (giftStrings.length > 0) {
|
||||
// 准备模板参数
|
||||
const params = {
|
||||
user: { name: user.name },
|
||||
gift: {
|
||||
summary: giftStrings.join(', '),
|
||||
totalPrice: user.totalPrice.toFixed(2)
|
||||
}
|
||||
};
|
||||
messages.push(formatMessage(template, params));
|
||||
}
|
||||
});
|
||||
|
||||
// 发送弹幕
|
||||
messages.forEach(msg => {
|
||||
if (msg) biliFunc.sendLiveDanmaku(roomId.value!, msg);
|
||||
});
|
||||
|
||||
// 如果还有未感谢的礼物,继续设置计时器
|
||||
if (aggregatedGifts.value.length > 0) {
|
||||
if (giftThankTimer.value) clearTimeout(giftThankTimer.value);
|
||||
giftThankTimer.value = setTimeout(sendGiftThankYou, giftThankConfig.value.delaySeconds * 1000);
|
||||
} else {
|
||||
giftThankTimer.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理上舰事件 (Guard)
|
||||
function onGuard(event: EventModel) {
|
||||
if (!shouldProcess(guardPmConfig.value) || !roomId.value) return;
|
||||
|
||||
const userId = event.uid;
|
||||
const userName = event.uname;
|
||||
const guardLevel = event.guard_level;
|
||||
|
||||
if (guardLevel === GuardLevel.None) return; // 不是上舰事件
|
||||
|
||||
// 防止重复发送
|
||||
if (guardPmConfig.value.preventRepeat) {
|
||||
if (sentGuardPms.value.has(userId)) {
|
||||
console.log(`用户 ${userName} (${userId}) 已发送过上舰私信,跳过。`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 查找礼品码
|
||||
let giftCode = '';
|
||||
if (guardPmConfig.value.giftCodeMode) {
|
||||
const levelCodes = guardPmConfig.value.giftCodes.find(gc => gc.level === guardLevel)?.codes;
|
||||
if (levelCodes && levelCodes.length > 0) {
|
||||
giftCode = levelCodes.shift() || '';
|
||||
// 更新储存的礼品码
|
||||
saveGuardConfig();
|
||||
} else {
|
||||
// 尝试查找通用码 (level 0)
|
||||
const commonCodes = guardPmConfig.value.giftCodes.find(gc => gc.level === GuardLevel.None)?.codes;
|
||||
if (commonCodes && commonCodes.length > 0) {
|
||||
giftCode = commonCodes.shift() || '';
|
||||
saveGuardConfig();
|
||||
} else {
|
||||
console.warn(`等级 ${guardLevel} 或通用礼品码已用完,无法发送给 ${userName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化私信内容
|
||||
const guardLevelName = { [GuardLevel.Zongdu]: '总督', [GuardLevel.Tidu]: '提督', [GuardLevel.Jianzhang]: '舰长' }[guardLevel] || '舰长';
|
||||
const pmParams = {
|
||||
user: { name: userName },
|
||||
guard: {
|
||||
levelName: guardLevelName,
|
||||
giftCode: giftCode
|
||||
}
|
||||
};
|
||||
const pmContent = formatMessage(guardPmConfig.value.template, pmParams);
|
||||
|
||||
// 发送私信
|
||||
biliFunc.sendPrivateMessage(userId, pmContent).then(success => {
|
||||
if (success) {
|
||||
console.log(`成功发送上舰私信给 ${userName} (${userId})`);
|
||||
if (guardPmConfig.value.preventRepeat) {
|
||||
sentGuardPms.value.add(userId);
|
||||
}
|
||||
// 发送弹幕确认
|
||||
if (guardPmConfig.value.sendDanmakuConfirm && guardPmConfig.value.danmakuTemplate) {
|
||||
const confirmParams = { user: { name: userName } };
|
||||
const confirmMsg = formatMessage(guardPmConfig.value.danmakuTemplate, confirmParams);
|
||||
biliFunc.sendLiveDanmaku(roomId.value!, confirmMsg);
|
||||
}
|
||||
} else {
|
||||
console.error(`发送上舰私信给 ${userName} (${userId}) 失败`);
|
||||
// 失败时归还礼品码
|
||||
if (giftCode && guardPmConfig.value.giftCodeMode) {
|
||||
returnGiftCode(guardLevel, giftCode);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 归还礼品码到列表
|
||||
function returnGiftCode(level: GuardLevel, code: string) {
|
||||
const levelCodes = guardPmConfig.value.giftCodes.find(gc => gc.level === level);
|
||||
if (levelCodes) {
|
||||
levelCodes.codes.push(code);
|
||||
} else {
|
||||
guardPmConfig.value.giftCodes.push({ level, codes: [code] });
|
||||
}
|
||||
saveGuardConfig();
|
||||
}
|
||||
|
||||
// 保存舰长配置到本地
|
||||
function saveGuardConfig() {
|
||||
// useStorage会自动保存,无需额外操作
|
||||
}
|
||||
|
||||
// 处理关注事件
|
||||
function onFollow(event: EventModel) {
|
||||
if (!shouldProcess(followThankConfig.value) || !roomId.value) return;
|
||||
if (followThankConfig.value.ignoreTianXuan && isTianXuanActive.value) return;
|
||||
|
||||
aggregatedFollows.value.push({ uid: event.uid, name: event.uname, timestamp: Date.now() });
|
||||
|
||||
if (followThankTimer.value) clearTimeout(followThankTimer.value);
|
||||
if (followThankConfig.value.delaySeconds > 0) {
|
||||
followThankTimer.value = setTimeout(sendFollowThankYou, followThankConfig.value.delaySeconds * 1000);
|
||||
} else {
|
||||
sendFollowThankYou();
|
||||
}
|
||||
}
|
||||
|
||||
// 发送关注感谢
|
||||
function sendFollowThankYou() {
|
||||
if (!roomId.value || aggregatedFollows.value.length === 0) return;
|
||||
const usersToThank = aggregatedFollows.value.slice(0, followThankConfig.value.maxUsersPerMsg);
|
||||
aggregatedFollows.value = aggregatedFollows.value.slice(followThankConfig.value.maxUsersPerMsg);
|
||||
|
||||
const template = getRandomTemplate(followThankConfig.value.templates);
|
||||
if (!template) return;
|
||||
|
||||
const names = usersToThank.map(u => u.name).join('、');
|
||||
const params = { user: { name: names } };
|
||||
const message = formatMessage(template, params);
|
||||
|
||||
if (message) biliFunc.sendLiveDanmaku(roomId.value!, message);
|
||||
|
||||
if (aggregatedFollows.value.length > 0) {
|
||||
if (followThankTimer.value) clearTimeout(followThankTimer.value);
|
||||
followThankTimer.value = setTimeout(sendFollowThankYou, followThankConfig.value.delaySeconds * 1000);
|
||||
} else {
|
||||
followThankTimer.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理入场事件 (Enter)
|
||||
function onEnter(event: EventModel) {
|
||||
if (!shouldProcess(entryWelcomeConfig.value) || !roomId.value) return;
|
||||
if (entryWelcomeConfig.value.ignoreTianXuan && isTianXuanActive.value) return;
|
||||
if (!checkUserFilter(entryWelcomeConfig.value, event)) return;
|
||||
|
||||
aggregatedEntries.value.push({ uid: event.uid, name: event.uname, timestamp: Date.now() });
|
||||
|
||||
if (entryWelcomeTimer.value) clearTimeout(entryWelcomeTimer.value);
|
||||
if (entryWelcomeConfig.value.delaySeconds > 0) {
|
||||
entryWelcomeTimer.value = setTimeout(sendEntryWelcome, entryWelcomeConfig.value.delaySeconds * 1000);
|
||||
} else {
|
||||
sendEntryWelcome();
|
||||
}
|
||||
}
|
||||
|
||||
// 发送入场欢迎
|
||||
function sendEntryWelcome() {
|
||||
if (!roomId.value || aggregatedEntries.value.length === 0) return;
|
||||
const usersToWelcome = aggregatedEntries.value.slice(0, entryWelcomeConfig.value.maxUsersPerMsg);
|
||||
aggregatedEntries.value = aggregatedEntries.value.slice(entryWelcomeConfig.value.maxUsersPerMsg);
|
||||
|
||||
const template = getRandomTemplate(entryWelcomeConfig.value.templates);
|
||||
if (!template) return;
|
||||
|
||||
const names = usersToWelcome.map(u => u.name).join('、');
|
||||
const params = { user: { name: names } };
|
||||
const message = formatMessage(template, params);
|
||||
|
||||
if (message) biliFunc.sendLiveDanmaku(roomId.value!, message);
|
||||
|
||||
if (aggregatedEntries.value.length > 0) {
|
||||
if (entryWelcomeTimer.value) clearTimeout(entryWelcomeTimer.value);
|
||||
entryWelcomeTimer.value = setTimeout(sendEntryWelcome, entryWelcomeConfig.value.delaySeconds * 1000);
|
||||
} else {
|
||||
entryWelcomeTimer.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 处理弹幕事件 (用于自动回复)
|
||||
function onDanmaku(event: EventModel) {
|
||||
if (!shouldProcess(autoReplyConfig.value) || !roomId.value) return;
|
||||
if (!checkUserFilter(autoReplyConfig.value, event)) return;
|
||||
|
||||
const message = event.msg;
|
||||
const userId = event.uid;
|
||||
const now = Date.now();
|
||||
|
||||
for (const rule of autoReplyConfig.value.rules) {
|
||||
const keywordMatch = rule.keywords.some(kw => message.includes(kw));
|
||||
if (!keywordMatch) continue;
|
||||
|
||||
const blockwordMatch = rule.blockwords.some(bw => message.includes(bw));
|
||||
if (blockwordMatch) continue; // 包含屏蔽词,不回复
|
||||
|
||||
// 检查冷却
|
||||
const ruleKey = rule.keywords.join('|');
|
||||
const lastReplyTime = lastReplyTimestamps.value[ruleKey] || 0;
|
||||
if (now - lastReplyTime < autoReplyConfig.value.cooldownSeconds * 1000) {
|
||||
continue; // 仍在冷却中
|
||||
}
|
||||
|
||||
// 选择回复并发送
|
||||
const reply = getRandomTemplate(rule.replies);
|
||||
if (reply) {
|
||||
const params = { user: { name: event.uname } };
|
||||
const formattedReply = formatMessage(reply, params);
|
||||
biliFunc.sendLiveDanmaku(roomId.value!, formattedReply);
|
||||
lastReplyTimestamps.value[ruleKey] = now; // 更新冷却时间
|
||||
break; // 匹配到一个规则就停止
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 发送定时弹幕
|
||||
function sendScheduledDanmaku() {
|
||||
if (!shouldProcess(scheduledDanmakuConfig.value) || !roomId.value || scheduledDanmakuConfig.value.messages.length === 0) {
|
||||
stopScheduledDanmaku(); // 停止计时器如果条件不满足
|
||||
return;
|
||||
}
|
||||
|
||||
let message = '';
|
||||
if (scheduledDanmakuConfig.value.mode === 'random') {
|
||||
message = getRandomTemplate(scheduledDanmakuConfig.value.messages);
|
||||
} else {
|
||||
message = scheduledDanmakuConfig.value.messages[currentScheduledIndex.value];
|
||||
currentScheduledIndex.value = (currentScheduledIndex.value + 1) % scheduledDanmakuConfig.value.messages.length;
|
||||
}
|
||||
|
||||
if (message) {
|
||||
biliFunc.sendLiveDanmaku(roomId.value!, message);
|
||||
}
|
||||
|
||||
// 设置下一次定时
|
||||
if (scheduledDanmakuTimer.value) clearTimeout(scheduledDanmakuTimer.value);
|
||||
scheduledDanmakuTimer.value = setTimeout(sendScheduledDanmaku, scheduledDanmakuConfig.value.intervalSeconds * 1000);
|
||||
}
|
||||
|
||||
// 启动定时弹幕
|
||||
function startScheduledDanmaku() {
|
||||
if (scheduledDanmakuTimer.value) clearTimeout(scheduledDanmakuTimer.value); // 清除旧的
|
||||
if (shouldProcess(scheduledDanmakuConfig.value) && scheduledDanmakuConfig.value.intervalSeconds > 0) {
|
||||
scheduledDanmakuTimer.value = setTimeout(sendScheduledDanmaku, scheduledDanmakuConfig.value.intervalSeconds * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// 停止定时弹幕
|
||||
function stopScheduledDanmaku() {
|
||||
if (scheduledDanmakuTimer.value) {
|
||||
clearTimeout(scheduledDanmakuTimer.value);
|
||||
scheduledDanmakuTimer.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 监听配置变化以启动/停止定时弹幕
|
||||
watch(() => [scheduledDanmakuConfig.value.enabled, scheduledDanmakuConfig.value.onlyDuringLive, isLive.value, scheduledDanmakuConfig.value.intervalSeconds], () => {
|
||||
if (scheduledDanmakuConfig.value.enabled && (!scheduledDanmakuConfig.value.onlyDuringLive || isLive.value)) {
|
||||
startScheduledDanmaku();
|
||||
} else {
|
||||
stopScheduledDanmaku();
|
||||
}
|
||||
}, { immediate: true }); // 立即执行一次检查
|
||||
|
||||
// 当组件卸载时清理所有计时器
|
||||
onUnmounted(() => {
|
||||
clearAllTimers();
|
||||
});
|
||||
|
||||
// 初始化,订阅事件
|
||||
function init() {
|
||||
danmakuClient.onEvent('danmaku', (data) => onDanmaku(data as EventModel));
|
||||
danmakuClient.onEvent('gift', (data) => onGift(data as EventModel));
|
||||
danmakuClient.onEvent('guard', (data) => onGuard(data as EventModel));
|
||||
danmakuClient.onEvent('follow', (data) => onFollow(data as EventModel));
|
||||
danmakuClient.onEvent('enter', (data) => onEnter(data as EventModel));
|
||||
|
||||
// 初始检查天选状态
|
||||
checkTianXuanStatus();
|
||||
|
||||
// 启动定时弹幕(如果初始状态满足条件)
|
||||
startScheduledDanmaku();
|
||||
|
||||
console.log('自动操作模块已初始化');
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
// --- 配置 ---
|
||||
giftThankConfig,
|
||||
guardPmConfig,
|
||||
followThankConfig,
|
||||
entryWelcomeConfig,
|
||||
scheduledDanmakuConfig,
|
||||
autoReplyConfig,
|
||||
};
|
||||
});
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept(acceptHMRUpdate(useAutoAction, import.meta.hot));
|
||||
}
|
||||
|
||||
export { GuardLevel };
|
||||
@@ -6,6 +6,7 @@ import { QueryBiliAPI } from '../data/utils';
|
||||
import { BiliUserProfile } from '../data/models';
|
||||
import { defineStore, acceptHMRUpdate } from 'pinia';
|
||||
import { ref, computed, shallowRef } from 'vue';
|
||||
import { StorageSerializers } from '@vueuse/core';
|
||||
|
||||
// --- 常量定义 ---
|
||||
// Tauri Store 存储键名
|
||||
@@ -72,7 +73,11 @@ type CookieCloudState = 'unset' | 'valid' | 'invalid' | 'syncing';
|
||||
export const useBiliCookie = defineStore('biliCookie', () => {
|
||||
// --- 依赖和持久化存储实例 ---
|
||||
// 使用 useTauriStore 获取持久化存储目标
|
||||
const biliCookieStore = useTauriStore().getTarget<BiliCookieStoreData>(BILI_COOKIE_KEY);
|
||||
const biliCookieStore = useStorage<BiliCookieStoreData>(BILI_COOKIE_KEY, {
|
||||
cookie: '',
|
||||
refreshToken: undefined, // 可选,未使用
|
||||
lastRefresh: new Date(0), // 默认值
|
||||
}); // 为保持响应性
|
||||
const cookieCloudStore = useTauriStore().getTarget<CookieCloudConfig>(COOKIE_CLOUD_KEY);
|
||||
const userInfoCacheStore = useTauriStore().getTarget<UserInfoCache>(USER_INFO_CACHE_KEY);
|
||||
|
||||
@@ -328,7 +333,7 @@ export const useBiliCookie = defineStore('biliCookie', () => {
|
||||
|
||||
// 1. 加载持久化数据
|
||||
const [storedCookieData, storedCloudConfig, storedUserInfo] = await Promise.all([
|
||||
biliCookieStore.get(),
|
||||
biliCookieStore.value,
|
||||
cookieCloudStore.get(),
|
||||
userInfoCacheStore.get(),
|
||||
]);
|
||||
@@ -411,7 +416,7 @@ export const useBiliCookie = defineStore('biliCookie', () => {
|
||||
// 如果没有尝试云同步,或者云同步失败,则检查本地 Cookie
|
||||
if (!cloudSyncAttempted || !cloudSyncSuccess) {
|
||||
debug('[BiliCookie] 检查本地存储的 Cookie 有效性...');
|
||||
const storedCookie = (await biliCookieStore.get())?.cookie;
|
||||
const storedCookie = biliCookieStore.value?.cookie;
|
||||
if (storedCookie) {
|
||||
const { valid } = await _checkCookieValidity(storedCookie);
|
||||
// 只有在云同步未成功时才更新状态,避免覆盖云同步设置的状态
|
||||
@@ -450,7 +455,7 @@ export const useBiliCookie = defineStore('biliCookie', () => {
|
||||
lastRefresh: new Date() // 更新刷新时间戳
|
||||
};
|
||||
try {
|
||||
await biliCookieStore.set(dataToStore);
|
||||
biliCookieStore.value = dataToStore; // 使用响应式存储
|
||||
info('[BiliCookie] 新 Bilibili Cookie 已验证并保存');
|
||||
_updateCookieState(true, true); // 更新状态为存在且有效
|
||||
} catch (err) {
|
||||
@@ -473,7 +478,7 @@ export const useBiliCookie = defineStore('biliCookie', () => {
|
||||
* @returns Promise<string | undefined> Cookie 字符串或 undefined
|
||||
*/
|
||||
const getBiliCookie = async (): Promise<string | undefined> => {
|
||||
const data = await biliCookieStore.get();
|
||||
const data = biliCookieStore.value;
|
||||
return data?.cookie;
|
||||
};
|
||||
|
||||
@@ -489,11 +494,7 @@ export const useBiliCookie = defineStore('biliCookie', () => {
|
||||
debug('[BiliCookie] 定时检查已停止');
|
||||
}
|
||||
// 清除 Cookie 存储
|
||||
try {
|
||||
await biliCookieStore.delete();
|
||||
} catch (err) {
|
||||
error('[BiliCookie] 清除 Bilibili Cookie 存储失败: ' + String(err));
|
||||
}
|
||||
biliCookieStore.value = undefined; // 清除持久化存储
|
||||
// 清除用户信息缓存
|
||||
await _clearUserInfoCache();
|
||||
// 重置状态变量
|
||||
@@ -563,6 +564,8 @@ export const useBiliCookie = defineStore('biliCookie', () => {
|
||||
uId: computed(() => uId.value), // 只读 ref
|
||||
userInfo, // computed 属性本身就是只读的
|
||||
|
||||
cookie: computed(() => biliCookieStore.value?.cookie), // 只读 ref
|
||||
|
||||
// 方法
|
||||
init,
|
||||
check, // 暴露 check 方法,允许手动触发检查 (例如,应用从后台恢复)
|
||||
|
||||
212
src/client/store/useBiliFunction.ts
Normal file
212
src/client/store/useBiliFunction.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { useAccount } from "@/api/account";
|
||||
import { useBiliCookie } from "./useBiliCookie";
|
||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; // 引入 Body
|
||||
import { defineStore, acceptHMRUpdate } from 'pinia';
|
||||
import { computed } from 'vue';
|
||||
|
||||
export const useBiliFunction = defineStore('biliFunction', () => {
|
||||
const biliCookieStore = useBiliCookie();
|
||||
const account = useAccount();
|
||||
const cookie = computed(() => biliCookieStore.cookie);
|
||||
const uid = computed(() => account.value.biliId);
|
||||
|
||||
const csrf = computed(() => {
|
||||
if (!cookie.value) return null;
|
||||
const match = cookie.value.match(/bili_jct=([^;]+)/);
|
||||
return match ? match[1] : null;
|
||||
});
|
||||
|
||||
/**
|
||||
* 发送直播弹幕
|
||||
* @param roomId 直播间 ID
|
||||
* @param message 弹幕内容
|
||||
* @param color 弹幕颜色 (十六进制, 如 FFFFFF)
|
||||
* @param fontsize 字体大小 (默认 25)
|
||||
* @param mode 弹幕模式 (1: 滚动, 4: 底部, 5: 顶部)
|
||||
* @returns Promise<boolean> 是否发送成功 (基于API响应码)
|
||||
*/
|
||||
async function sendLiveDanmaku(roomId: number, message: string, color: string = 'ffffff', fontsize: number = 25, mode: number = 1): Promise<boolean> {
|
||||
if (!csrf.value || !cookie.value) {
|
||||
console.error("发送弹幕失败:缺少 cookie 或 csrf token");
|
||||
return false;
|
||||
}
|
||||
if (!message || message.trim().length === 0) {
|
||||
console.warn("尝试发送空弹幕,已阻止。");
|
||||
return false;
|
||||
}
|
||||
const url = "https://api.live.bilibili.com/msg/send";
|
||||
const rnd = Math.floor(Date.now() / 1000);
|
||||
const data = {
|
||||
bubble: '0',
|
||||
msg: message,
|
||||
color: parseInt(color, 16).toString(),
|
||||
fontsize: fontsize.toString(),
|
||||
mode: mode.toString(),
|
||||
roomid: roomId.toString(),
|
||||
rnd: rnd.toString(),
|
||||
csrf: csrf.value,
|
||||
csrf_token: csrf.value,
|
||||
};
|
||||
|
||||
try {
|
||||
// 注意: B站网页版发送弹幕是用 application/x-www-form-urlencoded
|
||||
const response = await tauriFetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Cookie": cookie.value,
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.0.0 Safari/537.36",
|
||||
"Referer": `https://live.bilibili.com/${roomId}`
|
||||
},
|
||||
body: JSON.stringify(data), // 发送 JSON 数据
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("发送弹幕网络失败:", response.status, await response.text());
|
||||
return false;
|
||||
}
|
||||
const json = await response.json();
|
||||
// B站成功码通常是 0
|
||||
if (json.code !== 0) {
|
||||
console.error("发送弹幕API失败:", json.code, json.message || json.msg);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log("发送弹幕成功:", message);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("发送弹幕时发生错误:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 封禁直播间用户 (需要主播或房管权限)
|
||||
* @param roomId 直播间 ID
|
||||
* @param userId 要封禁的用户 UID
|
||||
* @param hours 封禁时长 (小时, 1-720)
|
||||
*/
|
||||
async function banLiveUser(roomId: number, userId: number, hours: number = 1) {
|
||||
// 使用 csrf.value
|
||||
if (!csrf.value || !cookie.value) {
|
||||
console.error("封禁用户失败:缺少 cookie 或 csrf token");
|
||||
return;
|
||||
}
|
||||
// 确保 hours 在 1 到 720 之间
|
||||
const validHours = Math.max(1, Math.min(hours, 720));
|
||||
const url = "https://api.live.bilibili.com/banned_service/v2/Silent/add_user";
|
||||
const data = {
|
||||
room_id: roomId.toString(),
|
||||
block_uid: userId.toString(),
|
||||
hour: validHours.toString(),
|
||||
csrf: csrf.value, // 使用计算属性的值
|
||||
csrf_token: csrf.value, // 使用计算属性的值
|
||||
visit_id: "", // 通常可以为空
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await tauriFetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Cookie": cookie.value, // 使用计算属性的值
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.0.0 Safari/537.36",
|
||||
"Referer": `https://live.bilibili.com/p/html/live-room-setting/#/room-manager/black-list?room_id=${roomId}` // 模拟来源
|
||||
},
|
||||
body: JSON.stringify(data), // 发送 JSON 数据
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error("封禁用户失败:", response.status, await response.text());
|
||||
return response.statusText;
|
||||
}
|
||||
const json = await response.json();
|
||||
if (json.code !== 0) {
|
||||
console.error("封禁用户API失败:", json.code, json.message || json.msg);
|
||||
return json.data;
|
||||
}
|
||||
console.log("封禁用户成功:", json.data);
|
||||
return json.data;
|
||||
} catch (error) {
|
||||
console.error("封禁用户时发生错误:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送私信
|
||||
* @param receiverId 接收者 UID
|
||||
* @param message 私信内容
|
||||
* @returns Promise<boolean> 是否发送成功 (基于API响应码)
|
||||
*/
|
||||
async function sendPrivateMessage(receiverId: number, message: string): Promise<boolean> {
|
||||
if (!csrf.value || !cookie.value || !uid.value) {
|
||||
console.error("发送私信失败:缺少 cookie, csrf token 或 uid");
|
||||
return false;
|
||||
}
|
||||
if (!message || message.trim().length === 0) {
|
||||
console.warn("尝试发送空私信,已阻止。");
|
||||
return false;
|
||||
}
|
||||
const url = "https://api.vc.bilibili.com/web_im/v1/web_im/send_msg";
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const content = JSON.stringify({ content: message });
|
||||
const dev_id = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16).toUpperCase();
|
||||
});
|
||||
const data = {
|
||||
'msg[sender_uid]': uid.value.toString(),
|
||||
'msg[receiver_id]': receiverId.toString(),
|
||||
'msg[receiver_type]': '1',
|
||||
'msg[msg_type]': '1',
|
||||
'msg[msg_status]': '0',
|
||||
'msg[content]': content,
|
||||
'msg[timestamp]': timestamp.toString(),
|
||||
'msg[new_face_version]': '0',
|
||||
'msg[dev_id]': dev_id,
|
||||
'build': '0',
|
||||
'mobi_app': 'web',
|
||||
'csrf': csrf.value,
|
||||
'csrf_token': csrf.value,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await tauriFetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Cookie": cookie.value,
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.0.0 Safari/537.36",
|
||||
"Referer": `https://message.bilibili.com/`,
|
||||
},
|
||||
body: JSON.stringify(data), // 发送 JSON 数据
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("发送私信网络失败:", response.status, await response.text());
|
||||
return false;
|
||||
}
|
||||
// 私信成功码也是 0
|
||||
if (response.data.code !== 0) {
|
||||
console.error("发送私信API失败:", response.data.code, response.data.message);
|
||||
return false;
|
||||
}
|
||||
console.log(`发送私信给 ${receiverId} 成功`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("发送私信时发生错误:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sendLiveDanmaku,
|
||||
banLiveUser,
|
||||
sendPrivateMessage,
|
||||
csrf,
|
||||
uid,
|
||||
};
|
||||
});
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept(acceptHMRUpdate(useBiliFunction, import.meta.hot));
|
||||
}
|
||||
@@ -66,16 +66,23 @@ function generateTestDanmaku(): EventModel {
|
||||
const randomTime = Date.now();
|
||||
const randomOuid = `oid_${randomUid}`;
|
||||
|
||||
// 扩展粉丝勋章相关的随机数据
|
||||
const hasMedal = Math.random() > 0.3; // 70% 概率拥有粉丝勋章
|
||||
const isWearingMedal = hasMedal && Math.random() > 0.2; // 佩戴粉丝勋章的概率
|
||||
const medalNames = ['鸽子团', '鲨鱼牌', '椰奶', '饼干', '猫猫头', '南极', '狗妈', '可爱', '团子', '喵'];
|
||||
const randomMedalName = medalNames[Math.floor(Math.random() * medalNames.length)];
|
||||
const randomMedalLevel = isWearingMedal ? Math.floor(Math.random() * 40) + 1 : 0;
|
||||
|
||||
const baseEvent: Partial<EventModel> = {
|
||||
name: randomName,
|
||||
uname: randomName,
|
||||
uface: `https://i0.hdslb.com/bfs/face/member/noface.jpg`, // Placeholder for user avatar
|
||||
uid: randomUid,
|
||||
open_id: randomOuid, // Assuming open_id is same as ouid for test
|
||||
time: randomTime,
|
||||
guard_level: Math.floor(Math.random() * 4) as GuardLevel,
|
||||
fans_medal_level: Math.floor(Math.random() * 41),
|
||||
fans_medal_name: '测试牌',
|
||||
fans_medal_wearing_status: Math.random() > 0.5,
|
||||
fans_medal_level: randomMedalLevel,
|
||||
fans_medal_name: randomMedalName,
|
||||
fans_medal_wearing_status: isWearingMedal,
|
||||
ouid: randomOuid,
|
||||
};
|
||||
|
||||
@@ -243,8 +250,6 @@ export const useDanmakuWindow = defineStore('danmakuWindow', () => {
|
||||
danmakuWindowSetting.value.y = position.y;
|
||||
});
|
||||
|
||||
isWindowOpened.value = true;
|
||||
|
||||
bc = new BroadcastChannel(DANMAKU_WINDOW_BROADCAST_CHANNEL);
|
||||
bc.onmessage = (event: MessageEvent<DanmakuWindowBCData>) => {
|
||||
if (event.data.type === 'window-ready') {
|
||||
@@ -350,7 +355,7 @@ export const useDanmakuWindow = defineStore('danmakuWindow', () => {
|
||||
// 新增:发送测试弹幕函数
|
||||
function sendTestDanmaku() {
|
||||
if (!isWindowOpened.value || !bc) {
|
||||
console.warn('[danmaku-window] 窗口未打开或 BC 未初始化,无法发送测试弹幕');
|
||||
console.warn('[danmaku-window] 窗口未打开或 BroadcastChannel 未初始化,无法发送测试弹幕');
|
||||
return;
|
||||
}
|
||||
const testData = generateTestDanmaku();
|
||||
|
||||
1
src/components.d.ts
vendored
1
src/components.d.ts
vendored
@@ -39,6 +39,7 @@ declare module 'vue' {
|
||||
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
|
||||
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||
NSpace: typeof import('naive-ui')['NSpace']
|
||||
NSSwitch: typeof import('naive-ui')['NSSwitch']
|
||||
NTab: typeof import('naive-ui')['NTab']
|
||||
NTag: typeof import('naive-ui')['NTag']
|
||||
NText: typeof import('naive-ui')['NText']
|
||||
|
||||
@@ -59,16 +59,16 @@ export default class DirectClient extends BaseDanmakuClient {
|
||||
d(
|
||||
{
|
||||
type: EventDataTypes.Message,
|
||||
name: info[2][1],
|
||||
uname: info[2][1],
|
||||
uid: info[2][0],
|
||||
msg: info[1],
|
||||
price: 0,
|
||||
num: 1,
|
||||
time: Date.now(),
|
||||
guard_level: info[7],
|
||||
fans_medal_level: info[0][15].medal?.level,
|
||||
fans_medal_name: info[0][15].medal?.name,
|
||||
fans_medal_wearing_status: info[0][15].medal?.is_light === 1,
|
||||
fans_medal_level: info[0][15].user.medal?.level,
|
||||
fans_medal_name: info[0][15].user.medal?.name,
|
||||
fans_medal_wearing_status: info[0][15].user.medal?.is_light === 1,
|
||||
emoji: info[0]?.[13]?.url?.replace("http://", "https://") || '',
|
||||
uface: info[0][15].user.base.face.replace("http://", "https://"),
|
||||
open_id: '',
|
||||
@@ -85,7 +85,7 @@ export default class DirectClient extends BaseDanmakuClient {
|
||||
d(
|
||||
{
|
||||
type: EventDataTypes.Gift,
|
||||
name: data.uname,
|
||||
uname: data.uname,
|
||||
uid: data.uid,
|
||||
msg: data.giftName,
|
||||
price: data.price / 1000,
|
||||
@@ -110,7 +110,7 @@ export default class DirectClient extends BaseDanmakuClient {
|
||||
d(
|
||||
{
|
||||
type: EventDataTypes.SC,
|
||||
name: data.user_info.uname,
|
||||
uname: data.user_info.uname,
|
||||
uid: data.uid,
|
||||
msg: data.message,
|
||||
price: data.price,
|
||||
@@ -135,7 +135,7 @@ export default class DirectClient extends BaseDanmakuClient {
|
||||
d(
|
||||
{
|
||||
type: EventDataTypes.Guard,
|
||||
name: data.username,
|
||||
uname: data.username,
|
||||
uid: data.uid,
|
||||
msg: data.gift_name,
|
||||
price: data.price / 1000,
|
||||
@@ -160,7 +160,7 @@ export default class DirectClient extends BaseDanmakuClient {
|
||||
d(
|
||||
{
|
||||
type: EventDataTypes.Enter,
|
||||
name: data.uname,
|
||||
uname: data.uname,
|
||||
uid: data.uid,
|
||||
msg: '',
|
||||
price: 0,
|
||||
@@ -185,7 +185,7 @@ export default class DirectClient extends BaseDanmakuClient {
|
||||
d(
|
||||
{
|
||||
type: EventDataTypes.SCDel,
|
||||
name: '',
|
||||
uname: '',
|
||||
uid: 0,
|
||||
msg: JSON.stringify(data.ids),
|
||||
price: 0,
|
||||
|
||||
@@ -125,7 +125,7 @@ export default class OpenLiveClient extends BaseDanmakuClient {
|
||||
d(
|
||||
{
|
||||
type: EventDataTypes.Message,
|
||||
name: data.uname,
|
||||
uname: data.uname,
|
||||
uid: data.uid,
|
||||
msg: data.msg,
|
||||
price: 0,
|
||||
@@ -154,7 +154,7 @@ export default class OpenLiveClient extends BaseDanmakuClient {
|
||||
d(
|
||||
{
|
||||
type: EventDataTypes.Gift,
|
||||
name: data.uname,
|
||||
uname: data.uname,
|
||||
uid: data.uid,
|
||||
msg: data.gift_name,
|
||||
price: data.paid ? price : -price,
|
||||
@@ -181,7 +181,7 @@ export default class OpenLiveClient extends BaseDanmakuClient {
|
||||
d(
|
||||
{
|
||||
type: EventDataTypes.SC,
|
||||
name: data.uname,
|
||||
uname: data.uname,
|
||||
uid: data.uid,
|
||||
msg: data.message,
|
||||
price: data.rmb,
|
||||
@@ -208,7 +208,7 @@ export default class OpenLiveClient extends BaseDanmakuClient {
|
||||
d(
|
||||
{
|
||||
type: EventDataTypes.Guard,
|
||||
name: data.user_info.uname,
|
||||
uname: data.user_info.uname,
|
||||
uid: data.user_info.uid,
|
||||
msg:
|
||||
data.guard_level == 1
|
||||
@@ -243,7 +243,7 @@ export default class OpenLiveClient extends BaseDanmakuClient {
|
||||
d(
|
||||
{
|
||||
type: EventDataTypes.Enter,
|
||||
name: data.uname,
|
||||
uname: data.uname,
|
||||
msg: '',
|
||||
price: 0,
|
||||
num: 0,
|
||||
@@ -270,7 +270,7 @@ export default class OpenLiveClient extends BaseDanmakuClient {
|
||||
d(
|
||||
{
|
||||
type: EventDataTypes.Enter,
|
||||
name: '',
|
||||
uname: '',
|
||||
msg: JSON.stringify(data.message_ids),
|
||||
price: 0,
|
||||
num: 0,
|
||||
|
||||
@@ -34,6 +34,14 @@ export default {
|
||||
title: '弹幕窗口管理',
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'auto-action',
|
||||
name: 'client-auto-action-manage',
|
||||
component: () => import('@/client/ClientAutoAction.vue'),
|
||||
meta: {
|
||||
title: '自动操作管理',
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'danmaku-window',
|
||||
name: 'client-danmaku-window-redirect',
|
||||
|
||||
@@ -253,11 +253,11 @@ async function getAllSong() {
|
||||
}
|
||||
async function addSong(danmaku: EventModel) {
|
||||
console.log(
|
||||
`[OPEN-LIVE-LIVE-REQUEST] 收到 [${danmaku.name}] 的点播${danmaku.type == EventDataTypes.SC ? 'SC' : '弹幕'}: ${danmaku.msg}`,
|
||||
`[OPEN-LIVE-LIVE-REQUEST] 收到 [${danmaku.uname}] 的点播${danmaku.type == EventDataTypes.SC ? 'SC' : '弹幕'}: ${danmaku.msg}`,
|
||||
)
|
||||
if (settings.value.enableOnStreaming && accountInfo.value?.streamerInfo?.isStreaming != true) {
|
||||
notice.info({
|
||||
title: `${danmaku.name} 点播失败`,
|
||||
title: `${danmaku.uname} 点播失败`,
|
||||
description: '当前未在直播中, 无法添加点播请求. 或者关闭设置中的仅允许直播时加入',
|
||||
meta: () => h(NTime, { type: 'relative', time: Date.now(), key: updateKey.value }),
|
||||
})
|
||||
@@ -266,18 +266,18 @@ async function addSong(danmaku: EventModel) {
|
||||
if (accountInfo.value) {
|
||||
await QueryPostAPI<SongRequestInfo>(SONG_REQUEST_API_URL + 'try-add', danmaku).then((data) => {
|
||||
if (data.code == 200) {
|
||||
message.success(`[${danmaku.name}] 添加曲目: ${data.data.songName}`)
|
||||
message.success(`[${danmaku.uname}] 添加曲目: ${data.data.songName}`)
|
||||
if (data.message != 'EventFetcher') originSongs.value.unshift(data.data)
|
||||
} else {
|
||||
//message.error(`[${danmaku.name}] 添加曲目失败: ${data.message}`)
|
||||
const time = Date.now()
|
||||
notice.warning({
|
||||
title: danmaku.name + ' 点播失败',
|
||||
title: danmaku.uname + ' 点播失败',
|
||||
description: data.message,
|
||||
duration: isWarnMessageAutoClose.value ? 3000 : 0,
|
||||
meta: () => h(NTime, { type: 'relative', time: time, key: updateKey.value }),
|
||||
})
|
||||
console.log(`[OPEN-LIVE-LIVE-REQUEST] [${danmaku.name}] 添加曲目失败: ${data.message}`)
|
||||
console.log(`[OPEN-LIVE-LIVE-REQUEST] [${danmaku.uname}] 添加曲目失败: ${data.message}`)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
@@ -288,7 +288,7 @@ async function addSong(danmaku: EventModel) {
|
||||
from: danmaku.type == EventDataTypes.Message ? SongRequestFrom.Danmaku : SongRequestFrom.SC,
|
||||
scPrice: danmaku.type == EventDataTypes.SC ? danmaku.price : 0,
|
||||
user: {
|
||||
name: danmaku.name,
|
||||
name: danmaku.uname,
|
||||
uid: danmaku.uid,
|
||||
oid: danmaku.open_id,
|
||||
face: danmaku.uface,
|
||||
@@ -302,7 +302,7 @@ async function addSong(danmaku: EventModel) {
|
||||
id: songs.value.length == 0 ? 1 : new List(songs.value).Max((s) => s.id) + 1,
|
||||
} as SongRequestInfo
|
||||
localActiveSongs.value.unshift(songData)
|
||||
message.success(`[${danmaku.name}] 添加: ${songData.songName}`)
|
||||
message.success(`[${danmaku.uname}] 添加: ${songData.songName}`)
|
||||
}
|
||||
}
|
||||
async function addSongManual() {
|
||||
|
||||
@@ -281,7 +281,7 @@ async function onGetEvent(data: EventModel) {
|
||||
const lastRequest = cooldown.value[data.uid]
|
||||
if (Date.now() - lastRequest < settings.value.orderCooldown * 1000) {
|
||||
message.info(
|
||||
`[${data.name}] 冷却中,距离下次点歌还有 ${((settings.value.orderCooldown * 1000 - (Date.now() - lastRequest)) / 1000).toFixed(1)} 秒`,
|
||||
`[${data.uname}] 冷却中,距离下次点歌还有 ${((settings.value.orderCooldown * 1000 - (Date.now() - lastRequest)) / 1000).toFixed(1)} 秒`,
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -290,13 +290,13 @@ async function onGetEvent(data: EventModel) {
|
||||
const result = await searchMusic(name)
|
||||
if (result) {
|
||||
if (settings.value.blacklist.includes(result.name)) {
|
||||
message.warning(`[${data.name}] 点歌失败,因为 ${result.name} 在黑名单中`)
|
||||
message.warning(`[${data.uname}] 点歌失败,因为 ${result.name} 在黑名单中`)
|
||||
return
|
||||
}
|
||||
cooldown.value[data.uid] = Date.now()
|
||||
const music = {
|
||||
from: {
|
||||
name: data.name,
|
||||
name: data.uname,
|
||||
uid: data.uid,
|
||||
guard_level: data.guard_level,
|
||||
fans_medal_level: data.fans_medal_level,
|
||||
|
||||
@@ -251,7 +251,7 @@
|
||||
if (!checkMessage(danmaku)) {
|
||||
return;
|
||||
}
|
||||
console.log(`[OPEN-LIVE-QUEUE] 收到 [${danmaku.name}] 的排队请求`);
|
||||
console.log(`[OPEN-LIVE-QUEUE] 收到 [${danmaku.uname}] 的排队请求`);
|
||||
// 检查是否仅直播时允许加入
|
||||
if (settings.value.enableOnStreaming && accountInfo.value?.streamerInfo?.isStreaming != true) {
|
||||
message.info('当前未在直播中, 无法添加排队请求. 或者关闭设置中的仅允许直播时加入');
|
||||
@@ -275,21 +275,21 @@
|
||||
originQueue.value.splice(existingIndex, 1, data.data); // 替换现有条目
|
||||
} else { // 新用户加入
|
||||
originQueue.value.push(data.data); // 添加到末尾 (排序由 computed 处理)
|
||||
message.success(`[${danmaku.name}] 添加至队列`);
|
||||
message.success(`[${danmaku.uname}] 添加至队列`);
|
||||
}
|
||||
}
|
||||
} else { // 添加失败
|
||||
const time = Date.now();
|
||||
notice.warning({
|
||||
title: danmaku.name + ' 排队失败',
|
||||
title: danmaku.uname + ' 排队失败',
|
||||
description: data.message,
|
||||
duration: isWarnMessageAutoClose.value ? 3000 : 0,
|
||||
meta: () => h(NTime, { type: 'relative', time: time, key: updateKey.value }), // 使用 updateKey 强制更新时间显示
|
||||
});
|
||||
console.log(`[OPEN-LIVE-QUEUE] [${danmaku.name}] 排队失败: ${data.message}`);
|
||||
console.log(`[OPEN-LIVE-QUEUE] [${danmaku.uname}] 排队失败: ${data.message}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error(`[${danmaku.name}] 添加队列时出错: ${err.message || err}`);
|
||||
message.error(`[${danmaku.uname}] 添加队列时出错: ${err.message || err}`);
|
||||
console.error(`[OPEN-LIVE-QUEUE] 添加队列出错:`, err);
|
||||
}
|
||||
} else { // 未登录,操作本地队列
|
||||
@@ -298,7 +298,7 @@
|
||||
from: danmaku.type == EventDataTypes.Message ? QueueFrom.Danmaku : QueueFrom.Gift,
|
||||
giftPrice: danmaku.type == EventDataTypes.SC ? danmaku.price : undefined,
|
||||
user: {
|
||||
name: danmaku.name,
|
||||
name: danmaku.uname,
|
||||
uid: danmaku.uid,
|
||||
oid: danmaku.open_id,
|
||||
fans_medal_level: danmaku.fans_medal_level,
|
||||
@@ -311,7 +311,7 @@
|
||||
id: localQueues.value.length == 0 ? 1 : new List(localQueues.value).Max((s) => s.id) + 1, // 本地 ID
|
||||
} as ResponseQueueModel;
|
||||
localQueues.value.unshift(songData); // 添加到本地队列开头
|
||||
message.success(`[${danmaku.name}] 添加至本地队列`);
|
||||
message.success(`[${danmaku.uname}] 添加至本地队列`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,7 +410,7 @@
|
||||
function checkMessage(eventData: EventModel): boolean {
|
||||
// 未登录时,如果用户已在本地队列,则不允许重复添加 (简单检查)
|
||||
if (!configCanEdit.value && localQueues.value.some((q) => q.user?.uid == eventData.uid && q.status < QueueStatus.Finish)) {
|
||||
console.log(`[OPEN-LIVE-QUEUE] 本地队列已存在用户 [${eventData.name}],跳过`);
|
||||
console.log(`[OPEN-LIVE-QUEUE] 本地队列已存在用户 [${eventData.uname}],跳过`);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -357,7 +357,7 @@ function onGetEvent(data: EventModel) {
|
||||
exist.combineCount ??= 0
|
||||
exist.combineCount += data.num
|
||||
console.log(
|
||||
`[TTS] ${data.name} 增加已存在礼物数量: ${data.msg} [${exist.data.num - data.num} => ${exist.data.num}]`,
|
||||
`[TTS] ${data.uname} 增加已存在礼物数量: ${data.msg} [${exist.data.num - data.num} => ${exist.data.num}]`,
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -401,7 +401,7 @@ function getTextFromDanmaku(data: EventModel | undefined) {
|
||||
text = text
|
||||
.replace(
|
||||
templateConstants.name.regex,
|
||||
settings.value.voiceType == 'api' && settings.value.splitText ? `'${data.name}'` : data.name,
|
||||
settings.value.voiceType == 'api' && settings.value.splitText ? `'${data.uname}'` : data.uname,
|
||||
)
|
||||
.replace(templateConstants.count.regex, data.num.toString())
|
||||
.replace(templateConstants.price.regex, data.price.toString())
|
||||
@@ -483,7 +483,7 @@ function test(type: EventDataTypes) {
|
||||
case EventDataTypes.Message:
|
||||
forceSpeak({
|
||||
type: EventDataTypes.Message,
|
||||
name: accountInfo.value?.name ?? '测试用户',
|
||||
uname: accountInfo.value?.name ?? '测试用户',
|
||||
uid: accountInfo.value?.biliId ?? 0,
|
||||
msg: '测试弹幕',
|
||||
price: 0,
|
||||
@@ -502,7 +502,7 @@ function test(type: EventDataTypes) {
|
||||
case EventDataTypes.SC:
|
||||
forceSpeak({
|
||||
type: EventDataTypes.SC,
|
||||
name: accountInfo.value?.name ?? '测试用户',
|
||||
uname: accountInfo.value?.name ?? '测试用户',
|
||||
uid: accountInfo.value?.biliId ?? 0,
|
||||
msg: '测试留言',
|
||||
price: 30,
|
||||
@@ -521,7 +521,7 @@ function test(type: EventDataTypes) {
|
||||
case EventDataTypes.Guard:
|
||||
forceSpeak({
|
||||
type: EventDataTypes.Guard,
|
||||
name: accountInfo.value?.name ?? '测试用户',
|
||||
uname: accountInfo.value?.name ?? '测试用户',
|
||||
uid: accountInfo.value?.biliId ?? 0,
|
||||
msg: '舰长',
|
||||
price: 0,
|
||||
@@ -540,7 +540,7 @@ function test(type: EventDataTypes) {
|
||||
case EventDataTypes.Gift:
|
||||
forceSpeak({
|
||||
type: EventDataTypes.Gift,
|
||||
name: accountInfo.value?.name ?? '测试用户',
|
||||
uname: accountInfo.value?.name ?? '测试用户',
|
||||
uid: accountInfo.value?.biliId ?? 0,
|
||||
msg: '测试礼物',
|
||||
price: 5,
|
||||
@@ -790,7 +790,7 @@ onUnmounted(() => {
|
||||
> SC</NTag>
|
||||
</span>
|
||||
<NText>
|
||||
{{ item.data.name }}
|
||||
{{ item.data.uname }}
|
||||
</NText>
|
||||
<NText depth="3">
|
||||
{{ getTextFromDanmaku(item.data) }}
|
||||
|
||||
Reference in New Issue
Block a user