Files
vtsuru.live/src/views/open_live/OpenQueue.vue
Megghy f9417870ce feat: 更新 SongList 组件,增强分页功能和样式优化
- 添加了每页大小的动态设置,支持用户自定义分页。
- 优化了 OpenLiveLayout 组件的侧边栏样式,提升了用户体验。
- 改进了 OpenLiveIndex 组件的卡片布局,增强了视觉效果。
- 更新了 OpenQueue 组件,增加了辅助函数以改善队列状态显示。
2025-04-20 15:01:21 +08:00

1962 lines
70 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { AddBiliBlackList, SaveEnableFunctions, SaveSetting, useAccount } from '@/api/account';
import {
DanmakuUserInfo,
EventDataTypes,
EventModel,
FunctionTypes,
KeywordMatchType,
OpenLiveInfo, // 保留 props 类型定义
QueueFrom,
QueueGiftFilterType,
QueueSortType,
QueueStatus,
ResponseQueueModel,
Setting_Queue,
} from '@/api/api-models';
import { QueryGetAPI, QueryPostAPI, QueryPostAPIWithParams } from '@/api/query';
import { CURRENT_HOST, QUEUE_API_URL } from '@/data/constants'; // CURRENT_HOST 用于 OBS Modal
import { useDanmakuClient } from '@/store/useDanmakuClient';
import {
Checkmark12Regular,
ClipboardTextLtr24Filled,
Delete24Filled,
Dismiss16Filled,
Info24Filled,
PeopleQueue24Filled,
PresenceBlocked16Regular,
} from '@vicons/fluent';
import { ReloadCircleSharp } from '@vicons/ionicons5';
import { useStorage } from '@vueuse/core';
import { isSameDay } from 'date-fns';
import { List } from 'linqts';
import {
DataTableColumns,
NAlert,
NButton,
NCard,
NCheckbox,
NCollapse,
NCollapseItem,
NDataTable,
NDivider,
NEmpty,
NIcon,
NInput,
NInputGroup,
NInputGroupLabel,
NInputNumber,
NLi,
NList,
NListItem,
NModal,
NPopconfirm,
NRadioButton,
NRadioGroup,
NSelect,
NSpace,
NSpin,
NSwitch,
NTabPane,
NTabs,
NTag,
NText,
NTime,
NTooltip,
NUl,
useMessage,
useNotification,
} from 'naive-ui';
import { computed, h, onActivated, onDeactivated, onMounted, onUnmounted, ref, VNodeChild, CSSProperties } from 'vue';
// import { useRoute } from 'vue-router' // 未使用
import QueueOBS from '../obs/QueueOBS.vue';
import { copyToClipboard } from '@/Utils';
// 默认队列设置
const defaultSettings = {
keyword: '排队',
enableOnStreaming: false,
queueMaxSize: 10,
allowAllDanmaku: true,
allowFromWeb: true,
needWearFanMedal: false,
needJianzhang: false,
needTidu: false,
needZongdu: false,
allowGift: true,
giftNames: [],
minGiftPrice: 0.1,
allowIncreaseByAnyPayment: true,
allowIncreasePaymentBySendGift: true,
fanMedalMinLevel: 0,
enableCooldown: false,
cooldownSecond: 86400,
zongduCooldownSecond: 10800,
tiduCooldownSecond: 21600,
jianzhangCooldownSecond: 43200,
matchType: KeywordMatchType.Contains,
sortType: QueueSortType.TimeFirst,
giftFilterType: QueueGiftFilterType.Or,
showRequireInfo: true,
isReverse: false,
showFanMadelInfo: true,
showPayment: true,
sendGiftDirectJoin: true,
sendGiftIgnoreLimit: false,
} as Setting_Queue;
// 队列状态映射
const STATUS_MAP = {
[QueueStatus.Waiting]: '等待中',
[QueueStatus.Progressing]: '处理中',
[QueueStatus.Finish]: '已完成',
[QueueStatus.Cancel]: '已取消',
};
// const route = useRoute() // 未使用
const accountInfo = useAccount();
const message = useMessage();
const notice = useNotification();
const client = await useDanmakuClient().initOpenlive(); // 初始化弹幕客户端
const isWarnMessageAutoClose = useStorage('Queue.Settings.WarnMessageAutoClose', false); // 警告消息是否自动关闭
const isReverse = useStorage('Queue.Settings.Reverse', false); // 本地存储的倒序设置 (未登录时使用)
// const volumn = useStorage('Settings.Volumn', 0.5) // 未使用
const isLoading = ref(false); // 加载状态
const showOBSModal = ref(false); // OBS 组件模态框显示状态
const filterName = ref(''); // 历史记录筛选用户名
const filterNameContains = ref(false); // 历史记录筛选是否包含
// 队列设置 (登录后使用账户设置, 否则使用默认设置)
const settings = computed({
get: () => {
if (accountInfo.value.id) {
return accountInfo.value.settings.queue;
}
return defaultSettings;
},
set: (value) => {
if (accountInfo.value.id) {
accountInfo.value.settings.queue = value;
}
},
});
// Props 定义 (虽然未在逻辑中直接使用,但可能由父组件传入或用于类型检查)
const props = defineProps<{
roomInfo?: OpenLiveInfo;
code?: string | undefined;
isOpenLive?: boolean;
}>();
const localQueues = useStorage('Local.Queue', [] as ResponseQueueModel[]); // 本地存储的队列 (未登录时使用)
const originQueue = ref<ResponseQueueModel[]>([]); // 从 API 获取或本地存储的原始队列数据
const queue = computed(() => { // 当前显示的活动队列 (过滤、排序后)
let list = new List(accountInfo.value ? originQueue.value : localQueues.value)
.Where( // 按用户名筛选
(q) =>
!filterName.value ||
(filterNameContains.value
? q?.user?.name.toLowerCase().includes(filterName.value.toLowerCase()) == true
: q?.user?.name.toLowerCase() == filterName.value.toLowerCase()),
)
.Where((q) => (q?.status ?? QueueStatus.Cancel) < QueueStatus.Finish); // 仅显示未完成或取消的
// 根据设置进行排序
switch (settings.value.sortType) {
case QueueSortType.TimeFirst: { // 时间优先
list = list.OrderBy((q) => q.createAt);
break;
}
case QueueSortType.GuardFirst: { // 舰长优先 (总督 > 提督 > 舰长 > 普通)
list = list
.OrderBy((q) => (q.user?.guard_level == 0 || q.user?.guard_level == null ? 4 : q.user.guard_level))
.ThenBy((q) => q.createAt);
break;
}
case QueueSortType.PaymentFist: { // 付费优先
list = list.OrderByDescending((q) => q.giftPrice).ThenBy((q) => q.createAt);
break;
}
case QueueSortType.FansMedalFirst: { // 粉丝牌优先 (佩戴 > 未佩戴, 等级高 > 等级低)
list = list
.OrderByDescending((q) => (q.user?.fans_medal_wearing_status ? 1 : 0))
.ThenByDescending((q) => q.user?.fans_medal_level ?? 0)
.ThenBy((q) => q.createAt);
break;
}
}
// 处理倒序
if (configCanEdit.value ? settings.value.isReverse : isReverse.value) {
list = list.Reverse();
}
// 将处理中的项置顶
list = list.OrderByDescending((q) => (q.status == QueueStatus.Progressing ? 1 : 0));
return list.ToArray();
});
const historySongs = computed(() => { // 历史队列 (已完成或取消)
return (accountInfo.value ? originQueue.value : localQueues.value)
.filter((song) => {
return song.status == QueueStatus.Finish || song.status == QueueStatus.Cancel;
})
.sort((a, b) => (b.finishAt ?? b.createAt) - (a.finishAt ?? a.createAt)); // 按完成/创建时间降序
});
const newQueueName = ref(''); // 手动添加的用户名
const defaultKeyword = useStorage('Settings.Queue.DefaultKeyword', '排队'); // 本地存储的默认关键词
const configCanEdit = computed(() => { // 配置是否可编辑 (是否已登录)
return accountInfo.value != null && accountInfo.value != undefined;
});
const table = ref(); // NDataTable 引用
// 获取所有队列数据
async function getAll() {
if (accountInfo.value.id) {
try {
isLoading.value = true;
const data = await QueryGetAPI<ResponseQueueModel[]>(QUEUE_API_URL + 'get-all', {
id: accountInfo.value.id,
});
if (data.code == 200) {
console.log('[OPEN-LIVE-Queue] 已获取所有数据');
return data.data ?? []; // 确保返回数组
} else {
message.error('无法获取队列数据: ' + data.message);
return [];
}
} catch (err: any) {
message.error('获取队列数据失败: ' + (err.message || err));
console.error('[OPEN-LIVE-Queue] 获取数据失败:', err);
return [];
} finally {
isLoading.value = false;
}
} else {
// 未登录时返回本地数据
return localQueues.value;
}
}
// 尝试添加队列 (处理弹幕、礼物事件)
async function add(danmaku: EventModel) {
// 检查功能是否启用
if (!accountInfo.value?.settings.enableFunctions.includes(FunctionTypes.Queue)) {
return;
}
// 检查消息是否符合加入条件
if (!checkMessage(danmaku)) {
return;
}
console.log(`[OPEN-LIVE-QUEUE] 收到 [${danmaku.uname}] 的排队请求`);
// 检查是否仅直播时允许加入
if (settings.value.enableOnStreaming && accountInfo.value?.streamerInfo?.isStreaming != true) {
message.info('当前未在直播中, 无法添加排队请求. 或者关闭设置中的仅允许直播时加入');
return;
}
if (accountInfo.value.id) { // 已登录,调用 API
try {
const data = await QueryPostAPI<ResponseQueueModel>(QUEUE_API_URL + 'try-add', danmaku);
if (data.code == 200) {
if (data.message != 'EventFetcher') { // 避免重复处理 EventFetcher 的消息
const existingIndex = originQueue.value.findIndex((q) => q.id == data.data.id);
if (existingIndex > -1) { // 用户已在队列中 (通常是送礼增加金额)
const oldPrice = originQueue.value[existingIndex]?.giftPrice ?? 0;
const newPrice = data.data?.giftPrice ?? 0;
if (newPrice > oldPrice) {
message.info(
`${data.data.user?.name} 通过发送礼物再次付费: ¥ ${(newPrice - oldPrice).toFixed(1)}, 当前总计付费: ¥ ${newPrice.toFixed(1)}`,
);
}
originQueue.value.splice(existingIndex, 1, data.data); // 替换现有条目
} else { // 新用户加入
originQueue.value.push(data.data); // 添加到末尾 (排序由 computed 处理)
message.success(`[${danmaku.uname}] 添加至队列`);
}
}
} else { // 添加失败
const time = Date.now();
notice.warning({
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.uname}] 排队失败: ${data.message}`);
}
} catch (err: any) {
message.error(`[${danmaku.uname}] 添加队列时出错: ${err.message || err}`);
console.error(`[OPEN-LIVE-QUEUE] 添加队列出错:`, err);
}
} else { // 未登录,操作本地队列
const songData = {
status: QueueStatus.Waiting,
from: danmaku.type == EventDataTypes.Message ? QueueFrom.Danmaku : QueueFrom.Gift,
giftPrice: danmaku.type == EventDataTypes.SC ? danmaku.price : undefined,
user: {
name: danmaku.uname,
uid: danmaku.uid,
oid: danmaku.open_id,
fans_medal_level: danmaku.fans_medal_level,
fans_medal_name: danmaku.fans_medal_name,
fans_medal_wearing_status: danmaku.fans_medal_wearing_status,
guard_level: danmaku.guard_level,
} as DanmakuUserInfo,
createAt: Date.now(),
isInLocal: true,
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.uname}] 添加至本地队列`);
}
}
// 手动添加用户至队列
async function addManual() {
if (!newQueueName.value) {
message.error('请输入用户名');
return;
}
if (accountInfo.value.id) { // 已登录,调用 API
try {
const data = await QueryPostAPIWithParams<ResponseQueueModel>(QUEUE_API_URL + 'add', {
name: newQueueName.value,
});
if (data.code == 200) {
message.success(`已手动添加用户至队列: ${data.data.user?.name}`);
originQueue.value.unshift(data.data); // 添加到原始队列开头
newQueueName.value = '';
console.log(`[OPEN-LIVE-QUEUE] 已手动添加用户至队列: ${data.data.user?.name}`);
} else {
message.error(`手动添加失败: ${data.message}`);
}
} catch (err: any) {
message.error(`手动添加时出错: ${err.message || err}`);
console.error(`[OPEN-LIVE-QUEUE] 手动添加出错:`, err);
}
} else { // 未登录,操作本地队列
const songData = {
status: QueueStatus.Waiting,
from: QueueFrom.Manual,
scPrice: undefined,
user: { name: newQueueName.value } as DanmakuUserInfo,
createAt: Date.now(),
isInLocal: true,
id: localQueues.value.length == 0 ? 1 : new List(localQueues.value).Max((s) => s.id) + 1,
} as ResponseQueueModel;
localQueues.value.unshift(songData);
message.success(`已手动添加用户至队列: ${newQueueName.value}`);
newQueueName.value = '';
}
}
// 更新队列状态
async function updateStatus(queueData: ResponseQueueModel, status: QueueStatus) {
if (!configCanEdit.value) { // 未登录,直接修改本地状态
const localItem = localQueues.value.find(q => q.id === queueData.id);
if (localItem) {
localItem.status = status;
if (status > QueueStatus.Progressing) {
localItem.finishAt = Date.now();
}
message.success(`已更新本地 [${queueData.user?.name}] 队列状态为: ${STATUS_MAP[status]}`);
}
return;
}
// 已登录,调用 API
isLoading.value = true;
try {
const data = await QueryGetAPI(QUEUE_API_URL + 'set-status', {
id: queueData.id,
status: status,
});
if (data.code == 200) {
console.log(`[OPEN-LIVE-QUEUE] 更新队列状态: ${queueData.user?.name} -> ${STATUS_MAP[status]}`);
// 直接修改原始数据以触发响应式更新
const itemInOrigin = originQueue.value.find(q => q.id === queueData.id);
if (itemInOrigin) {
itemInOrigin.status = status;
if (status > QueueStatus.Progressing) {
itemInOrigin.finishAt = Date.now();
}
}
message.success(`已更新 [${queueData.user?.name}] 队列状态为: ${STATUS_MAP[status]}`);
} else {
console.log(`[OPEN-LIVE-QUEUE] 更新队列状态失败: ${data.message}`);
message.error(`更新队列状态失败: ${data.message}`);
}
} catch (err: any) {
message.error(`更新队列状态时出错: ${err.message || err}`);
console.error(`[OPEN-LIVE-QUEUE] 更新状态出错:`, err);
} finally {
isLoading.value = false;
}
}
// 弹幕事件处理
function onGetDanmaku(danmaku: EventModel) {
add(danmaku);
}
// 礼物事件处理
function onGetGift(danmaku: EventModel) {
add(danmaku);
}
// 检查消息是否符合加入队列的条件
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.uname}],跳过`);
return false;
}
// 检查弹幕消息
if (eventData.type === EventDataTypes.Message) {
if (!settings.value.keyword) return false; // 未设置关键词则不允许弹幕加入
if (!checkMatch(eventData.msg)) {
return false; // 弹幕内容不匹配关键词
}
}
// 检查礼物消息
else if (eventData.type === EventDataTypes.Gift) {
// 如果不允许礼物加入,并且不允许通过送礼增加金额,则直接拒绝
if (!settings.value.allowGift && !settings.value.allowIncreasePaymentBySendGift) {
return false;
}
// 如果允许礼物加入,则进行详细检查
if (settings.value.allowGift) {
const nameMatch = (settings.value.giftNames?.length ?? 0) === 0 || // 未设置礼物名要求
settings.value.giftNames?.some((n) => eventData.msg.toLowerCase() === n.toLowerCase()) == true; // 礼物名匹配
const priceMatch = !settings.value.minGiftPrice || eventData.price >= settings.value.minGiftPrice; // 价格匹配
if (settings.value.giftFilterType === QueueGiftFilterType.Or) { // 或逻辑:满足任一即可
if (!nameMatch && !priceMatch) return false; // 名称和价格都不满足
} else { // 与逻辑:必须同时满足
if (!nameMatch || !priceMatch) return false; // 名称或价格不满足
}
// 如果设置了送礼直接加入,则检查通过
if (settings.value.sendGiftDirectJoin) return true;
// 如果未设置直接加入,则送礼本身不触发加入,需要额外发弹幕
else return false;
}
// 如果只允许通过送礼增加金额 (不允许直接通过礼物加入)
else if (settings.value.allowIncreasePaymentBySendGift) {
// 检查是否允许任意礼物叠加 或 礼物是否在指定列表内
const isAllowedGiftForIncrease = settings.value.allowIncreaseByAnyPayment ||
settings.value.giftNames?.some((n) => eventData.msg.toLowerCase() === n.toLowerCase()) == true;
// 只有当礼物允许叠加时,才认为这是一个有效的(潜在增加金额的)事件,但不直接触发加入
return isAllowedGiftForIncrease;
} else {
return false; // 其他情况不允许
}
}
// 检查 SC 消息 (如果需要单独处理)
// else if (eventData.type === EventDataTypes.SC) { ... }
return true; // 默认通过 (例如,手动添加或网页添加不经过此检查)
// 内部函数:检查关键词匹配
function checkMatch(word: string): boolean {
const keyword = settings.value.keyword?.trim().toLowerCase();
if (!keyword) return false; // 没有关键词直接返回 false
const message = word.trim().toLowerCase();
switch (settings.value.matchType) {
case KeywordMatchType.Full:
return keyword === message;
case KeywordMatchType.Contains:
return message.includes(keyword);
case KeywordMatchType.Regex:
try {
return new RegExp(settings.value.keyword).test(word); // 使用原始 keyword 进行正则匹配
} catch (e) {
console.warn('[OPEN-LIVE-QUEUE] 正则表达式无效:', settings.value.keyword, e);
return false;
}
default:
return false;
}
}
}
// 更新功能启用状态
async function onUpdateFunctionEnable() {
if (accountInfo.value.id) {
const oldValue = JSON.parse(JSON.stringify(accountInfo.value.settings.enableFunctions));
const isEnabling = !accountInfo.value.settings.enableFunctions.includes(FunctionTypes.Queue);
if (isEnabling) {
accountInfo.value.settings.enableFunctions.push(FunctionTypes.Queue);
// 启用时检查并设置默认关键词
if (!accountInfo.value.settings.queue.keyword) {
accountInfo.value.settings.queue.keyword = defaultKeyword.value;
// 同时保存一次设置以确保关键词生效
await updateSettings();
}
} else {
accountInfo.value.settings.enableFunctions = accountInfo.value.settings.enableFunctions.filter(
(f) => f != FunctionTypes.Queue,
);
}
try {
const data = await SaveEnableFunctions(accountInfo.value?.settings.enableFunctions);
if (data.code == 200) {
message.success(`${isEnabling ? '启用' : '禁用'}队列功能`);
} else {
// 回滚状态
if (accountInfo.value.id) {
accountInfo.value.settings.enableFunctions = oldValue;
}
message.error(`队列功能${isEnabling ? '启用' : '禁用'}失败: ${data.message}`);
}
} catch (err: any) {
// 回滚状态
if (accountInfo.value.id) {
accountInfo.value.settings.enableFunctions = oldValue;
}
message.error(`队列功能${isEnabling ? '启用' : '禁用'}失败: ${err.message || err}`);
console.error(`[OPEN-LIVE-QUEUE] 更新功能状态失败:`, err);
}
}
}
// 更新设置
async function updateSettings() {
if (accountInfo.value.id) {
isLoading.value = true;
try {
const success = await SaveSetting('Queue', settings.value);
if (success) {
message.success('设置已保存');
} else {
message.error('设置保存失败'); // API 应该返回更详细的信息,但这里简化处理
}
} catch (err: any) {
message.error(`保存设置失败: ${err.message || err}`);
console.error(`[OPEN-LIVE-QUEUE] 保存设置失败:`, err);
} finally {
isLoading.value = false;
}
} else {
message.success('本地设置已更新 (未登录)'); // 对于未登录用户,设置是响应式的,无需显式保存
}
}
// 删除队列记录
async function deleteQueue(values: ResponseQueueModel[]) {
if (!values || values.length === 0) return;
if (accountInfo.value.id) { // 已登录,调用 API
isLoading.value = true;
try {
const idsToDelete = values.map((s) => s.id);
const data = await QueryPostAPI(QUEUE_API_URL + 'del', idsToDelete);
if (data.code == 200) {
message.success(`成功删除 ${values.length} 条记录`);
// 从原始数据中移除已删除项
originQueue.value = originQueue.value.filter((s) => !idsToDelete.includes(s.id));
} else {
message.error('删除失败: ' + data.message);
console.error('[OPEN-LIVE-QUEUE] 删除失败: ' + data.message);
}
} catch (err: any) {
message.error(`删除记录时出错: ${err.message || err}`);
console.error('[OPEN-LIVE-QUEUE] 删除记录出错:', err);
} finally {
isLoading.value = false;
}
} else { // 未登录,操作本地队列
const idsToDelete = values.map(v => v.id);
localQueues.value = localQueues.value.filter(q => !idsToDelete.includes(q.id));
message.success(`成功删除 ${values.length} 条本地记录`);
}
}
// 取消所有活动队列项
async function deactiveAllSongs() {
if (accountInfo.value.id) { // 已登录,调用 API
isLoading.value = true;
try {
const data = await QueryGetAPI(QUEUE_API_URL + 'deactive');
if (data.code == 200) {
message.success('已全部取消');
// 更新本地状态
originQueue.value.forEach((s) => {
if (s.status <= QueueStatus.Progressing) {
s.status = QueueStatus.Cancel;
s.finishAt = Date.now(); // 标记完成时间
}
});
} else {
message.error('全部取消失败: ' + data.message);
}
} catch (err: any) {
message.error(`全部取消时出错: ${err.message || err}`);
console.error('[OPEN-LIVE-QUEUE] 全部取消出错:', err);
} finally {
isLoading.value = false;
}
} else { // 未登录,操作本地队列
localQueues.value.forEach((s) => {
if (s.status <= QueueStatus.Progressing) {
s.status = QueueStatus.Cancel;
s.finishAt = Date.now();
}
});
message.success('已全部取消本地活动队列');
}
}
// 状态筛选选项
const statusFilterOptions = computed(() => {
return Object.values(QueueStatus)
.filter((t): t is QueueStatus => typeof t === 'number') // 确保是数字枚举值
.map((t) => {
return {
label: STATUS_MAP[t],
value: t,
};
});
});
// 历史记录表格列定义
const columns = computed<DataTableColumns<ResponseQueueModel>>(() => [
{
title: '用户名',
key: 'user.name',
render: (data) => {
return h(
NTooltip,
{ trigger: 'hover' },
{
trigger: () => data.user?.name || '未知用户',
default: () => (data.from == QueueFrom.Manual ? '主播手动添加' : `UID: ${data.user?.uid ?? 'N/A'}`),
},
);
},
filterOptionValue: null, // 用于触发筛选
filter: (value, row) => { // 使用 NDataTable 内置筛选
const name = row.user?.name?.toLowerCase() ?? '';
const filterVal = filterName.value.toLowerCase();
if (!filterVal) return true;
return filterNameContains.value ? name.includes(filterVal) : name === filterVal;
}
},
{
title: '来源',
key: 'from',
width: 120,
render(data) {
let fromType: 'info' | 'success' | 'default' | 'error' = 'default';
let text = '';
switch (data.from) {
case QueueFrom.Danmaku: {
fromType = 'info';
text = '弹幕' + (data.giftPrice ? ` | ¥${data.giftPrice.toFixed(1)}` : '');
break;
}
case QueueFrom.Gift: {
fromType = 'error';
text = '礼物 | ¥' + (data.giftPrice?.toFixed(1) ?? '0.0');
break;
}
case QueueFrom.Web: {
fromType = 'success';
text = '网页添加';
break;
}
case QueueFrom.Manual: {
fromType = 'default';
text = '手动添加';
break;
}
default: text = '未知';
}
return h(NTag, { size: 'small', type: fromType, bordered: false }, () => text);
},
},
{
title: '状态',
key: 'status',
filterMultiple: false, // 只允许单选
filterOptions: statusFilterOptions.value, // 使用计算属性
filter: (value, row) => {
return row.status === value;
},
render(data) {
let statusType: 'info' | 'success' | 'warning' | 'error';
switch (data.status) {
case QueueStatus.Progressing: {
statusType = 'success';
break;
}
case QueueStatus.Waiting: {
statusType = 'warning';
break;
}
case QueueStatus.Finish: {
statusType = 'info';
break;
}
case QueueStatus.Cancel: {
statusType = 'error';
break;
}
}
return h(
NTag,
{
type: statusType,
size: 'small',
bordered: false,
// 处理中状态添加动画效果
style: data.status == QueueStatus.Progressing ? 'animation: animated-border 2.5s infinite;' : '',
},
() => STATUS_MAP[data.status] ?? '未知状态',
);
},
},
{
title: '时间',
key: 'createAt', // 使用 createAt 作为 key 以便排序
sorter: 'default', // 使用 NDataTable 内置排序
render: (data) => {
return h(NTime, { time: data.createAt, type: 'datetime' }); // 显示完整时间
},
},
{
title: '操作',
key: 'manage',
width: 120, // 稍微加宽以容纳按钮
align: 'center',
render(data) {
const buttons: VNodeChild[] = [];
// 重新排队按钮 (仅对已完成或取消的显示)
if (data.status == QueueStatus.Finish || data.status == QueueStatus.Cancel) {
buttons.push(
h(NTooltip, null, {
trigger: () =>
h(
NButton,
{
size: 'tiny', // 统一尺寸
type: 'info',
circle: true,
loading: isLoading.value && queueDataBeingManaged.value === data.id, // 仅当前操作项显示 loading
onClick: () => {
queueDataBeingManaged.value = data.id; // 标记正在操作的项
updateStatus(data, QueueStatus.Waiting);
},
style: 'margin: 0 2px;',
},
{
icon: () => h(NIcon, { component: ReloadCircleSharp }),
},
),
default: () => '重新放回等待',
}),
);
}
// 删除按钮
buttons.push(
h(
NPopconfirm,
{ onPositiveClick: () => deleteQueue([data]) },
{
trigger: () =>
h(NTooltip, null, {
trigger: () =>
h(
NButton,
{
size: 'tiny', // 统一尺寸
type: 'error',
circle: true,
loading: isLoading.value && queueDataBeingManaged.value === data.id,
onClick: () => queueDataBeingManaged.value = data.id, // 标记以便显示 loading
style: 'margin: 0 2px;',
},
{
icon: () => h(NIcon, { component: Delete24Filled }),
},
),
default: () => '删除记录',
}),
default: () => `确定删除 ${data.user?.name} 的记录吗?`,
},
),
);
return h(NSpace, { justify: 'center', size: 4 }, () => buttons); // 减小间距
},
},
]);
// 用于标记历史记录表格中正在操作的行,以显示 loading
const queueDataBeingManaged = ref<number | null>(null);
// 监听筛选条件变化,手动触发 NDataTable 筛选
watch([filterName, filterNameContains], () => {
if (table.value) {
// 更新第一列的 filterOptionValue 来触发筛选
const cols = table.value.columns;
cols[0].filterOptionValue = filterName.value + filterNameContains.value.toString();
table.value.filter(cols[0]);
}
});
// 获取舰长等级对应的颜色
function GetGuardColor(level: number | null | undefined): string {
if (level) {
switch (level) {
case 1: return 'rgb(122, 4, 35)'; // 总督
case 2: return 'rgb(157, 155, 255)'; // 提督 (颜色可能需要调整)
case 3: return 'rgb(104, 136, 241)'; // 舰长
}
}
return '#999'; // 默认颜色或无舰长
}
// 定时更新活动队列信息 (增量更新)
async function updateActive() {
if (!accountInfo.value.id) return; // 未登录则不执行
try {
const data = await QueryGetAPI<ResponseQueueModel[]>(QUEUE_API_URL + 'get-active', {
id: accountInfo.value?.id,
});
if (data.code == 200) {
const activeItems = data.data ?? [];
activeItems.forEach((item) => {
const queueDataIndex = originQueue.value.findIndex((s) => s.id == item.id);
if (queueDataIndex > -1) { // 更新现有项
const queueData = originQueue.value[queueDataIndex];
// 仅在状态或价格变化时更新,减少不必要的响应式触发
let updated = false;
if (queueData.status !== item.status) {
queueData.status = item.status;
updated = true;
}
if (queueData.giftPrice !== item.giftPrice) {
const oldPrice = queueData.giftPrice ?? 0;
const newPrice = item.giftPrice ?? 0;
if (newPrice > oldPrice) { // 仅在价格增加时提示
message.info(
`${queueData.user?.name} 通过发送礼物再次付费: ¥ ${(newPrice - oldPrice).toFixed(1)}, 当前总计付费: ¥ ${newPrice.toFixed(1)}`,
);
}
queueData.giftPrice = item.giftPrice;
updated = true;
}
// 如果有其他需要同步的字段,在此处添加比较和更新
// if (updated) {
// // 可以考虑是否需要强制更新整个对象以确保响应性,但通常直接修改属性即可
// // originQueue.value.splice(queueDataIndex, 1, { ...queueData });
// }
} else { // 添加新项
originQueue.value.unshift(item); // 添加到开头,让排序处理
if (item.from == QueueFrom.Web) {
message.success(`[${item.user?.name}] 通过网页加入队列`);
} else if (item.from == QueueFrom.Gift && settings.value.sendGiftDirectJoin) {
message.success(`[${item.user?.name}] 通过礼物加入队列`);
}
// 其他来源的添加消息在 add 函数中处理
}
});
// 可选:移除本地存在但远程 active 接口未返回的非终态项 (表示可能被后台清理)
// const activeIds = new Set(activeItems.map(i => i.id));
// originQueue.value = originQueue.value.filter(q => q.status >= QueueStatus.Finish || activeIds.has(q.id));
} else {
// message.error('无法获取活动队列: ' + data.message) // 频繁请求,失败时不提示用户
console.warn('[OPEN-LIVE-Queue] 无法获取活动队列: ' + data.message);
}
} catch (err: any) {
console.warn('[OPEN-LIVE-Queue] 更新活动队列失败:', err.message || err);
}
}
// 拉黑用户 (仅限弹幕来源)
function blockUser(item: ResponseQueueModel) {
if (item.from != QueueFrom.Danmaku && item.from != QueueFrom.Gift) { // 允许拉黑礼物用户
message.error(`[${item.user?.name}] 不是来自弹幕或礼物的用户,无法拉黑`);
return;
}
if (item.user?.uid) { // 确保有 UID
isLoading.value = true; // 开始加载
queueDataBeingManaged.value = item.id; // 标记操作项
AddBiliBlackList(item.user.uid, item.user.name)
.then((data) => {
if (data.code == 200) {
message.success(`[${item.user?.name}] 已添加到 B站黑名单`);
updateStatus(item, QueueStatus.Cancel); // 拉黑后自动取消排队
} else {
message.error(`拉黑失败: ${data.message}`);
}
})
.catch((err: any) => {
message.error(`拉黑时发生错误: ${err.message || err}`);
console.error('[OPEN-LIVE-QUEUE] 拉黑用户出错:', err);
})
.finally(() => {
isLoading.value = false;
queueDataBeingManaged.value = null;
});
} else {
message.error(`用户 [${item.user?.name}] 没有有效的 UID无法拉黑`);
}
}
let timer: any; // 用于更新相对时间的计时器
let updateActiveTimer: any; // 用于轮询活动队列的计时器
const updateKey = ref(0); // 用于强制更新 NTime 组件
// 初始化操作
async function init() {
dispose(); // 先清理旧的计时器
// 如果登录了,获取一次全量数据
if (accountInfo.value.id) {
originQueue.value = await getAll();
}
// 设置定时器
timer = setInterval(() => {
updateKey.value++; // 每秒更新 key强制 NTime 更新相对时间
}, 1000);
updateActiveTimer = setInterval(() => {
updateActive(); // 定期更新活动队列
}, 5000); // 轮询间隔调整为 5 秒
}
// 清理操作
function dispose() {
clearInterval(timer);
clearInterval(updateActiveTimer);
timer = null;
updateActiveTimer = null;
}
// --- 生命周期钩子 ---
onMounted(async () => {
// 挂载时初始化
if (accountInfo.value.id) {
// 如果已登录,同步一次设置到本地状态 (虽然 computed 会处理,但显式同步更清晰)
settings.value = accountInfo.value.settings.queue;
}
// 绑定弹幕和礼物事件监听器
client.onEvent('danmaku', onGetDanmaku);
client.onEvent('gift', onGetGift);
await init(); // 初始化数据和定时器
});
onActivated(async () => {
// 组件被 keep-alive 激活时重新初始化
await init();
});
onDeactivated(() => {
// 组件被 keep-alive 停用时清理定时器
dispose();
});
onUnmounted(() => {
// 组件卸载时彻底清理
client.offEvent('danmaku', onGetDanmaku);
client.offEvent('gift', onGetGift);
dispose();
});
// --- 辅助函数 ---
function getIndexStyle(status: QueueStatus): CSSProperties {
// 基础颜色定义 - 扁平化风格
let backgroundColor;
// 根据状态设置不同的颜色
switch (status) {
case QueueStatus.Progressing:
backgroundColor = '#18a058'; // 处理中 - 绿色
break;
case QueueStatus.Waiting:
backgroundColor = '#2080f0'; // 等待中 - 蓝色
break;
case QueueStatus.Finish:
backgroundColor = '#86909c'; // 已完成 - 灰色
break;
case QueueStatus.Cancel:
backgroundColor = '#d03050'; // 已取消 - 红色
break;
default:
backgroundColor = '#2080f0'; // 默认 - 蓝色
}
const style: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 'bold',
width: '24px', // 确保宽高一致以形成完美圆形
height: '24px', // 保持一致的宽高
borderRadius: '50%', // 圆形
color: 'white',
fontSize: '13px', // 适当调整字体大小
backgroundColor, // 扁平化的纯色背景
transition: 'opacity 0.2s', // 仅保留简单的过渡效果
};
return style;
}
</script>
<template>
<!-- 功能启用开关 -->
<NAlert
v-if="accountInfo?.id"
:type="accountInfo.settings.enableFunctions.includes(FunctionTypes.Queue) ? 'success' : 'warning'"
title="弹幕队列功能"
closable
>
<template #header>
<NSpace align="center">
<NText>启用弹幕队列功能</NText>
<NSwitch
:value="accountInfo?.settings.enableFunctions.includes(FunctionTypes.Queue)"
:loading="isLoading"
@update:value="onUpdateFunctionEnable"
/>
</NSpace>
</template>
<NText depth="3">
如果没有部署
<NButton
text
type="primary"
tag="a"
href="https://www.wolai.com/fje5wLtcrDoZcb9rk2zrFs"
target="_blank"
>
VtsuruEventFetcher
</NButton>
则其需要保持此页面开启才能点播, 也不要同时开多个页面, 会导致点播重复 (部署了则不影响)
</NText>
</NAlert>
<!-- 未登录提示 -->
<NAlert
v-else
type="warning"
title="未登录"
closable
>
你尚未注册并登录 VTsuru.live部分功能和设置将不可用队列将在本地临时存储
<NButton
tag="a"
href="/manage"
target="_blank"
type="primary"
size="small"
style="margin-left: 10px;"
>
前往登录或注册
</NButton>
</NAlert>
<NCard
size="small"
style="margin-top: 10px;"
>
<NSpace align="center">
<!-- OBS 组件按钮 -->
<NTooltip :disabled="configCanEdit">
<template #trigger>
<NButton
type="primary"
:disabled="!configCanEdit"
@click="showOBSModal = true"
>
OBS 组件
</NButton>
</template>
登录后可使用 OBS 组件功能
</NTooltip>
<!-- 其他全局操作按钮可以在这里添加 -->
</NSpace>
</NCard>
<NCard style="margin-top: 10px;">
<!-- 主内容区域 -->
<NTabs
v-if="!accountInfo || accountInfo.settings.enableFunctions.includes(FunctionTypes.Queue)"
type="line"
animated
display-directive="show:lazy"
pane-style="padding-top: 10px;"
>
<!-- 队列列表 Tab -->
<NTabPane
name="list"
tab="当前队列"
>
<NCard
size="small"
:bordered="false"
>
<NSpace
align="center"
justify="space-between"
wrap
:item-style="{ marginBottom: '8px' }"
>
<!-- 队列统计信息 -->
<NSpace align="center">
<NTag
type="info"
:bordered="false"
round
>
<template #icon>
<NIcon :component="PeopleQueue24Filled" />
</template>
等待中: {{ queue.filter((s) => s.status == QueueStatus.Waiting).length }}
</NTag>
<NTag
type="success"
:bordered="false"
round
>
<template #icon>
<NIcon :component="Checkmark12Regular" />
</template>
今日已处理:
{{ historySongs.filter((s) => s.status == QueueStatus.Finish && isSameDay(s.finishAt ?? 0, Date.now())).length }}
</NTag>
</NSpace>
<!-- 手动添加 -->
<NInputGroup style="max-width: 250px;">
<NInput
v-model:value="newQueueName"
placeholder="手动添加用户"
clearable
@keyup.enter="addManual"
/>
<NButton
type="primary"
ghost
:disabled="!newQueueName"
@click="addManual"
>
添加
</NButton>
</NInputGroup>
<!-- 排序和操作 -->
<NSpace align="center">
<NPopconfirm @positive-click="deactiveAllSongs">
<template #trigger>
<NButton
type="error"
size="small"
ghost
>
全部取消
</NButton>
</template>
确定要取消所有等待中和处理中的队列项吗?
</NPopconfirm>
<NRadioGroup
v-model:value="settings.sortType"
:disabled="!configCanEdit"
size="small"
@update:value="updateSettings"
>
<NRadioButton :value="QueueSortType.TimeFirst">
时间
</NRadioButton>
<NRadioButton :value="QueueSortType.PaymentFist">
付费
</NRadioButton>
<NRadioButton :value="QueueSortType.GuardFirst">
舰长
</NRadioButton>
<NRadioButton :value="QueueSortType.FansMedalFirst">
粉丝牌
</NRadioButton>
</NRadioGroup>
<NCheckbox
v-if="configCanEdit"
v-model:checked="settings.isReverse"
size="small"
@update:checked="updateSettings"
>
倒序
</NCheckbox>
<NCheckbox
v-else
v-model:checked="isReverse"
size="small"
>
倒序
</NCheckbox>
</NSpace>
</NSpace>
</NCard>
<NDivider style="margin: 10px 0;" />
<!-- 队列列表 -->
<NSpin :show="isLoading && originQueue.length === 0">
<NList
v-if="queue.length > 0"
hoverable
clickable
style="max-height: 60vh; overflow-y: auto;"
>
<NListItem
v-for="(queueData, index) in queue"
:key="queueData.id"
style="padding: 5px 0;"
>
<NCard
embedded
size="small"
content-style="padding: 8px 12px;"
:bordered="queueData.status == QueueStatus.Progressing"
:style="queueData.status == QueueStatus.Progressing ? 'border-left: 4px solid #63e2b7;' : 'border-left: 4px solid transparent;'"
>
<NSpace
justify="space-between"
align="center"
:wrap="false"
>
<!-- 左侧信息 -->
<NSpace
align="center"
:size="8"
:wrap="false"
>
<span
:style="getIndexStyle(queueData.status)"
class="queue-index"
:class="{ 'queue-index-processing': queueData.status === QueueStatus.Progressing }"
>
{{ index + 1 }}
</span>
<NText
strong
style="font-size: 16px;"
>
<NTooltip>
<template #trigger>
{{ queueData.user?.name }}
</template>
UID: {{ queueData.user?.uid ?? 'N/A' }}
</NTooltip>
</NText>
<!-- 粉丝牌 -->
<NTag
v-if="settings.showFanMadelInfo && queueData.user?.fans_medal_wearing_status"
size="tiny"
round
:bordered="false"
:color="{ color: '#f0f0f0', textColor: '#555' }"
style="padding: 0 5px 0 0;"
>
<NTag
size="tiny"
round
:bordered="false"
type="info"
style="margin-right: 3px;"
>
{{ queueData.user?.fans_medal_level }}
</NTag>
{{ queueData.user?.fans_medal_name }}
</NTag>
<!-- 舰长 -->
<NTag
v-if="(queueData.user?.guard_level ?? 0) > 0"
size="small"
:bordered="false"
:color="{ textColor: 'white', color: GetGuardColor(queueData.user?.guard_level) }"
>
{{ queueData.user?.guard_level == 1 ? '总督' : queueData.user?.guard_level == 2 ? '提督' : '舰长' }}
</NTag>
<!-- 付费信息 -->
<NTag
v-if="settings.showPayment && (queueData.giftPrice ?? 0) > 0"
size="small"
:bordered="false"
type="error"
>
¥ {{ queueData.giftPrice?.toFixed(1) }}
</NTag>
<!-- 附加内容提示 -->
<NTooltip
v-if="queueData.content"
placement="right"
>
<template #trigger>
<NIcon
:component="Info24Filled"
size="16"
style="cursor: help; color: #aaa;"
/>
</template>
<NCard
size="small"
:bordered="false"
style="max-width: 300px;"
>
<template #header>
<span style="font-size: small; color: gray;">
{{ '来自' + (queueData?.from == QueueFrom.Gift ? '礼物' : '弹幕') + ': ' }}
</span>
</template>
{{ queueData?.content }}
</NCard>
</NTooltip>
<!-- 时间 -->
<NTooltip placement="bottom">
<template #trigger>
<NText
depth="3"
style="font-size: 12px;"
>
<NTime
:key="updateKey"
:time="queueData.createAt"
type="relative"
/>
</NText>
</template>
<NTime
:time="queueData.createAt"
format="yyyy-MM-dd HH:mm:ss"
/>
</NTooltip>
</NSpace>
<!-- 右侧操作按钮 -->
<NSpace
justify="end"
align="center"
:size="6"
:wrap="false"
style="flex-shrink: 0;"
>
<!-- 开始/暂停处理 -->
<NTooltip>
<template #trigger>
<NButton
circle
size="small"
:type="queueData.status == QueueStatus.Progressing ? 'warning' : 'primary'"
:ghost="queueData.status == QueueStatus.Progressing"
:disabled="queue.some((s) => s.id != queueData.id && s.status == QueueStatus.Progressing)"
:loading="isLoading && queueDataBeingManaged === queueData.id"
@click="
queueDataBeingManaged = queueData.id;
updateStatus(
queueData,
queueData.status == QueueStatus.Progressing ? QueueStatus.Waiting : QueueStatus.Progressing,
)
"
>
<template #icon>
<NIcon :component="ClipboardTextLtr24Filled" />
</template>
</NButton>
</template>
{{
queue.some((s) => s.id != queueData.id && s.status == QueueStatus.Progressing)
? '已有其他用户正在处理中'
: queueData.status == QueueStatus.Waiting
? '开始处理'
: '暂停处理 (返回等待)'
}}
</NTooltip>
<!-- 完成 -->
<NTooltip>
<template #trigger>
<NButton
circle
size="small"
type="success"
:loading="isLoading && queueDataBeingManaged === queueData.id"
@click="queueDataBeingManaged = queueData.id; updateStatus(queueData, QueueStatus.Finish)"
>
<template #icon>
<NIcon :component="Checkmark12Regular" />
</template>
</NButton>
</template>
标记为已完成
</NTooltip>
<!-- 拉黑 -->
<NTooltip
v-if="configCanEdit && (queueData.from == QueueFrom.Danmaku || queueData.from == QueueFrom.Gift) && queueData.user?.uid"
>
<template #trigger>
<NPopconfirm @positive-click="blockUser(queueData)">
<template #trigger>
<NButton
circle
size="small"
type="warning"
:loading="isLoading && queueDataBeingManaged === queueData.id"
@click="queueDataBeingManaged = queueData.id"
>
<template #icon>
<NIcon :component="PresenceBlocked16Regular" />
</template>
</NButton>
</template>
确定要将 {{ queueData.user?.name }} 加入 黑名单并取消排队吗
</NPopconfirm>
</template>
拉黑用户 (B站)
</NTooltip>
<!-- 移出/取消 -->
<NTooltip>
<template #trigger>
<NButton
circle
size="small"
type="error"
:loading="isLoading && queueDataBeingManaged === queueData.id"
@click="queueDataBeingManaged = queueData.id; updateStatus(queueData, QueueStatus.Cancel)"
>
<template #icon>
<NIcon :component="Dismiss16Filled" />
</template>
</NButton>
</template>
取消排队
</NTooltip>
</NSpace>
</NSpace>
</NCard>
</NListItem>
</NList>
<NEmpty
v-else
description="当前队列为空"
style="margin-top: 50px;"
/>
</NSpin>
</NTabPane>
<!-- 历史记录 Tab -->
<NTabPane
name="history"
tab="历史记录"
>
<NCard
size="small"
:bordered="false"
style="margin-bottom: 10px;"
>
<NSpace
align="center"
justify="space-between"
>
<NSpace align="center">
<NInputGroup style="width: 300px">
<NInputGroupLabel> 筛选用户 </NInputGroupLabel>
<NInput
v-model:value="filterName"
clearable
placeholder="输入用户名"
/>
</NInputGroup>
<NCheckbox v-model:checked="filterNameContains">
模糊匹配
</NCheckbox>
</NSpace>
<NButton
size="small"
type="error"
ghost
:disabled="historySongs.length === 0"
@click="deleteQueue(historySongs)"
>
清空所有历史记录
</NButton>
</NSpace>
</NCard>
<NDataTable
ref="table"
size="small"
:columns="columns"
:data="historySongs"
:pagination="{ pageSize: 20, showSizePicker: true, pageSizes: [20, 50, 100] }"
:loading="isLoading"
remote
:row-key="(row) => row.id"
striped
/>
</NTabPane>
<!-- 设置 Tab -->
<NTabPane
name="setting"
tab="设置"
:disabled="!configCanEdit"
>
<NSpin :show="isLoading">
<NSpace
vertical
:size="20"
style="padding-top: 10px;"
>
<!-- 加入规则 -->
<NCard
size="small"
title="加入规则"
:bordered="false"
>
<NSpace
vertical
:size="12"
>
<NSpace align="center">
<NInputGroup style="width: 350px">
<NInputGroupLabel> 弹幕关键词 </NInputGroupLabel>
<NInput
v-model:value="settings.keyword"
placeholder="留空则禁用弹幕加入"
@change="updateSettings"
/>
</NInputGroup>
<NRadioGroup
v-model:value="settings.matchType"
type="button"
size="small"
@update:value="updateSettings"
>
<NRadioButton :value="KeywordMatchType.Full">
完全
</NRadioButton>
<NRadioButton :value="KeywordMatchType.Contains">
包含
</NRadioButton>
<NRadioButton :value="KeywordMatchType.Regex">
正则
</NRadioButton>
</NRadioGroup>
</NSpace>
<NInputGroup style="width: 250px">
<NInputGroupLabel> 最大队列长度 </NInputGroupLabel>
<NInputNumber
v-model:value="settings.queueMaxSize"
min="0"
max="1000"
placeholder="0为不限制"
@update:value="updateSettings"
/>
</NInputGroup>
<NCheckbox
v-model:checked="settings.enableOnStreaming"
@update:checked="updateSettings"
>
仅在直播时允许加入
</NCheckbox>
<NDivider
title-placement="left"
style="margin: 5px 0;"
>
用户限制
</NDivider>
<NCheckbox
v-model:checked="settings.allowAllDanmaku"
@update:checked="updateSettings"
>
允许所有用户通过弹幕加入 (无视下方限制)
</NCheckbox>
<NSpace
v-if="!settings.allowAllDanmaku"
vertical
:size="10"
style="margin-left: 20px;"
>
<NInputGroup style="width: 270px">
<NInputGroupLabel> 最低粉丝牌等级 </NInputGroupLabel>
<NInputNumber
v-model:value="settings.fanMedalMinLevel"
min="0"
@update:value="updateSettings"
/>
</NInputGroup>
<NCheckbox
v-model:checked="settings.needJianzhang"
@update:checked="updateSettings"
>
允许舰长
</NCheckbox>
<NCheckbox
v-model:checked="settings.needTidu"
@update:checked="updateSettings"
>
允许提督
</NCheckbox>
<NCheckbox
v-model:checked="settings.needZongdu"
@update:checked="updateSettings"
>
允许总督
</NCheckbox>
</NSpace>
</NSpace>
</NCard>
<NDivider />
<!-- 礼物规则 -->
<NCard
size="small"
title="礼物规则"
:bordered="false"
>
<NSpace
vertical
:size="12"
>
<NCheckbox
v-model:checked="settings.allowGift"
@update:checked="updateSettings"
>
允许通过发送指定礼物直接加入队列
</NCheckbox>
<NSpace
v-if="settings.allowGift"
vertical
:size="10"
style="margin-left: 20px;"
>
<NInputGroup style="width: 250px">
<NInputGroupLabel> 最低礼物价值 (元) </NInputGroupLabel>
<NInputNumber
v-model:value="settings.minGiftPrice"
:min="0.1"
:step="0.1"
@update:value="updateSettings"
/>
</NInputGroup>
<NSpace align="center">
<NText> 指定礼物名称 </NText>
<NSelect
v-model:value="settings.giftNames"
style="width: 300px"
filterable
multiple
tag
placeholder="输入礼物名按回车确认, 留空则不限名称"
:show-arrow="false"
:show="false"
@update:value="updateSettings"
/>
</NSpace>
<NRadioGroup
v-model:value="settings.giftFilterType"
size="small"
@update:value="updateSettings"
>
<NRadioButton :value="QueueGiftFilterType.And">
需同时满足名称和价值
</NRadioButton>
<NRadioButton :value="QueueGiftFilterType.Or">
满足名称或价值之一
</NRadioButton>
</NRadioGroup>
<NCheckbox
v-model:checked="settings.sendGiftDirectJoin"
@update:checked="updateSettings"
>
赠送符合条件的礼物后自动加入队列
<NTooltip>
<template #trigger>
<NIcon
:component="Info24Filled"
size="14"
style="vertical-align: middle; margin-left: 2px;"
/>
</template>
如果不勾选,用户送礼后仍需发送排队弹幕才能加入。
</NTooltip>
</NCheckbox>
<NCheckbox
v-model:checked="settings.sendGiftIgnoreLimit"
@update:checked="updateSettings"
>
赠送符合条件的礼物后无视上述用户限制 (粉丝牌/舰长等)
</NCheckbox>
</NSpace>
<NDivider style="margin: 5px 0;" />
<NCheckbox
v-model:checked="settings.allowIncreasePaymentBySendGift"
@update:checked="updateSettings"
>
允许通过送礼累计队列中的付费金额 (影响付费排序)
</NCheckbox>
<NSpace
v-if="settings.allowIncreasePaymentBySendGift"
style="margin-left: 20px;"
>
<NCheckbox
v-model:checked="settings.allowIncreaseByAnyPayment"
@update:checked="updateSettings"
>
允许发送任意礼物叠加金额 (否则仅限上方指定的礼物)
</NCheckbox>
</NSpace>
</NSpace>
</NCard>
<NDivider />
<!-- 冷却时间 (CD) -->
<NCard
size="small"
title="冷却时间 (CD)"
:bordered="false"
>
<NCheckbox
v-model:checked="settings.enableCooldown"
@update:checked="updateSettings"
>
启用排队冷却 (用户完成后需等待一段时间才能再次加入)
</NCheckbox>
<NSpace
v-if="settings.enableCooldown"
vertical
:size="10"
style="margin-left: 20px; margin-top: 10px;"
>
<NInputGroup style="width: 280px">
<NInputGroupLabel> 普通用户 CD (秒) </NInputGroupLabel>
<NInputNumber
v-model:value="settings.cooldownSecond"
min="0"
@update:value="updateSettings"
/>
</NInputGroup>
<NInputGroup style="width: 280px">
<NInputGroupLabel> 舰长 CD (秒) </NInputGroupLabel>
<NInputNumber
v-model:value="settings.jianzhangCooldownSecond"
min="0"
@update:value="updateSettings"
/>
</NInputGroup>
<NInputGroup style="width: 280px">
<NInputGroupLabel> 提督 CD (秒) </NInputGroupLabel>
<NInputNumber
v-model:value="settings.tiduCooldownSecond"
min="0"
@update:value="updateSettings"
/>
</NInputGroup>
<NInputGroup style="width: 280px">
<NInputGroupLabel> 总督 CD (秒) </NInputGroupLabel>
<NInputNumber
v-model:value="settings.zongduCooldownSecond"
min="0"
@update:value="updateSettings"
/>
</NInputGroup>
</NSpace>
</NCard>
<NDivider />
<!-- 显示与界面 -->
<NCard
size="small"
title="显示与界面"
:bordered="false"
>
<NSpace
vertical
:size="12"
>
<NDivider
title-placement="left"
style="margin: 5px 0;"
>
OBS 组件显示
</NDivider>
<NCheckbox
v-model:checked="settings.showRequireInfo"
@update:checked="updateSettings"
>
在 OBS 组件底部显示加入要求信息
</NCheckbox>
<NCheckbox
v-model:checked="settings.showPayment"
@update:checked="updateSettings"
>
在 OBS 组件和列表项中显示付费金额
</NCheckbox>
<NCheckbox
v-model:checked="settings.showFanMadelInfo"
@update:checked="updateSettings"
>
在 OBS 组件和列表项中显示用户粉丝牌
</NCheckbox>
<NDivider
title-placement="left"
style="margin: 5px 0;"
>
其他界面设置
</NDivider>
<NCheckbox v-model:checked="isWarnMessageAutoClose">
自动关闭"加入队列失败"的通知消息 (默认3秒)
</NCheckbox>
</NSpace>
</NCard>
</NSpace>
</NSpin>
</NTabPane>
</NTabs>
<!-- 未启用功能时的提示 -->
<NAlert
v-else
title="功能未启用"
type="info"
>
请在页面顶部的开关处启用弹幕队列功能。
</NAlert>
</NCard>
<!-- OBS 组件模态框 -->
<NModal
v-model:show="showOBSModal"
preset="card"
style="width: 90%; max-width: 600px;"
title="OBS 浏览器源组件"
closable
>
<NAlert
title="使用方法"
type="info"
style="margin-bottom: 15px;"
>
将下方链接添加为 OBS 或其他直播软件的浏览器源,即可在直播画面中显示队列。
</NAlert>
<NInputGroup style="margin-bottom: 15px;">
<NInputGroupLabel> URL </NInputGroupLabel>
<NInput
:value="`${CURRENT_HOST}obs/queue?id=` + accountInfo?.id"
readonly
/>
<NButton
type="primary"
ghost
@click="copyToClipboard(`${CURRENT_HOST}obs/queue?id=${accountInfo?.id}`)"
>
复制
</NButton>
</NInputGroup>
<NDivider> 预览 (尺寸可能与实际不同) </NDivider>
<div
style="height: 450px; width: 280px; position: relative; margin: 0 auto; border: 1px dashed #ccc; overflow: hidden;"
>
<QueueOBS
v-if="accountInfo?.id"
:id="accountInfo.id"
/>
<NEmpty
v-else
description="无法预览未获取到用户信息"
style="padding-top: 100px;"
/>
</div>
<NCollapse
style="margin-top: 15px;"
accordion
>
<NCollapseItem title="详细说明">
<NUl>
<NLi>在 OBS 中添加一个新的"浏览器"来源。</NLi>
<NLi>将上方 URL 粘贴到"URL"栏中。</NLi>
<NLi>推荐宽度设置为 280-350px高度根据需要调整 (例如 500-700px)。</NLi>
<NLi>可在"设置"标签页中调整 OBS 组件的显示内容。</NLi>
<NLi>如果需要自定义样式,可以在 OBS 的"自定义 CSS"中添加覆盖样式。</NLi>
</NUl>
</NCollapseItem>
</NCollapse>
</NModal>
</template>
<style>
/* 处理中状态的边框动画 */
@keyframes animated-border {
0% {
box-shadow: 0 0 0 0px rgba(103, 194, 58, 0.7);
}
70% {
box-shadow: 0 0 0 5px rgba(103, 194, 58, 0);
}
100% {
box-shadow: 0 0 0 0px rgba(103, 194, 58, 0);
}
}
/* 处理中状态的卡片左边框或标签动画 */
.n-card[style*="border-left: 4px solid #63e2b7;"],
.n-tag--success[style*="animation: animated-border"] {
animation: animated-border 1.5s infinite;
}
/* 优化 NDataTable 内容过长时的显示 */
.n-data-table-td {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 序号悬停效果 - 扁平化风格 */
.queue-index:hover {
opacity: 0.85;
}
/* 处理中状态的序号动画 - 扁平化风格 */
.queue-index-processing {
position: relative;
}
.queue-index-processing::after {
content: '';
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
border-radius: 50%;
border: 2px solid #18a058;
opacity: 0;
animation: flat-pulse 2s infinite;
}
@keyframes flat-pulse {
0% {
transform: scale(1);
opacity: 0.7;
}
70% {
transform: scale(1.1);
opacity: 0;
}
100% {
transform: scale(1.1);
opacity: 0;
}
}
</style>