feat: 优化弹幕动效, 开始自动操作编写

This commit is contained in:
2025-04-18 02:34:20 +08:00
parent d53295bb0c
commit 5891f20f86
29 changed files with 2528 additions and 184 deletions

View File

@@ -550,7 +550,7 @@ export enum QueueStatus {
}
export interface EventModel {
type: EventDataTypes
name: string
uname: string
uface: string
uid: number
open_id: string

View 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>

View File

@@ -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>

View File

@@ -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'"

View File

@@ -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' } }, () => '设置'),

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -136,7 +136,7 @@ const priceText = computed(() => {
// 获取用户名显示
const displayName = computed(() => {
return props.item.name || '匿名用户';
return props.item.uname || '匿名用户';
});
// 获取消息显示内容

View File

@@ -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;

View File

@@ -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 // 添加粉丝勋章颜色计算属性
};
}

View File

@@ -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('弹幕窗口已打开');
}

View 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 };

View File

@@ -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 方法,允许手动触发检查 (例如,应用从后台恢复)

View 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));
}

View File

@@ -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
View File

@@ -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']

View File

@@ -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,

View File

@@ -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,

View File

@@ -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',

View File

@@ -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() {

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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) }}