mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-06 18:36:55 +08:00
- 添加了每页大小的动态设置,支持用户自定义分页。 - 优化了 OpenLiveLayout 组件的侧边栏样式,提升了用户体验。 - 改进了 OpenLiveIndex 组件的卡片布局,增强了视觉效果。 - 更新了 OpenQueue 组件,增加了辅助函数以改善队列状态显示。
1962 lines
70 KiB
Vue
1962 lines
70 KiB
Vue
<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>
|