mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-06 18:36:55 +08:00
feat: 添加弹幕投票相关功能, 修复礼物兑换外部链接bug
- 在api-models.ts中定义弹幕投票相关类型 - 在constants.ts中添加VOTE_API_URL常量 - 在路由中添加弹幕投票管理和OBS视图 - 更新组件以支持弹幕投票功能
This commit is contained in:
19
.cursorrules
Normal file
19
.cursorrules
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
- @src/api/api-models.ts: 定义了系统中使用的数据模型
|
||||||
|
- @src/api/query.ts: 提供了API请求的基础函数
|
||||||
|
- @src/api/account.ts: 账户管理相关API
|
||||||
|
|
||||||
|
## 主要目录结构
|
||||||
|
|
||||||
|
- `src/`: 源代码目录
|
||||||
|
- `api/`: API调用和模型定义
|
||||||
|
- `assets/`: 静态资源文件
|
||||||
|
- `client/`: 客户端相关组件和服务
|
||||||
|
- `components/`: Vue组件
|
||||||
|
- `composables/`: Vue组合式API函数
|
||||||
|
- `data/`: 数据相关模块,包括聊天和弹幕客户端
|
||||||
|
- `router/`: 路由配置
|
||||||
|
- `store/`: 状态管理
|
||||||
|
- `views/`: 页面视图组件
|
||||||
|
- `open_live/`: 直播相关视图,包括点歌系统
|
||||||
|
- `obs/`: OBS相关视图组件
|
||||||
|
- `public/`: 公共静态资源
|
||||||
@@ -883,3 +883,78 @@ export interface ExtendedUploadFileInfo {
|
|||||||
thumbnailUrl?: string; // 缩略图URL
|
thumbnailUrl?: string; // 缩略图URL
|
||||||
file?: File; // 可选的文件对象
|
file?: File; // 可选的文件对象
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 弹幕投票相关类型定义
|
||||||
|
export enum VoteResultMode {
|
||||||
|
ByCount = 0, // 按人数计票
|
||||||
|
ByGiftValue = 1 // 按礼物价值计票
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface APIFileModel {
|
||||||
|
id: number;
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
hash: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoteConfig {
|
||||||
|
isEnabled: boolean;
|
||||||
|
showResults: boolean;
|
||||||
|
voteDurationSeconds: number;
|
||||||
|
voteCommand: string;
|
||||||
|
voteEndCommand: string;
|
||||||
|
voteTitle: string;
|
||||||
|
allowMultipleOptions: boolean;
|
||||||
|
allowMultipleVotes: boolean;
|
||||||
|
allowCustomOptions: boolean;
|
||||||
|
logVotes: boolean;
|
||||||
|
defaultOptions: string[];
|
||||||
|
backgroundFile?: APIFileModel;
|
||||||
|
backgroundColor: string;
|
||||||
|
textColor: string;
|
||||||
|
optionColor: string;
|
||||||
|
roundedCorners: boolean;
|
||||||
|
displayPosition: string;
|
||||||
|
allowGiftVoting: boolean;
|
||||||
|
minGiftPrice?: number;
|
||||||
|
voteResultMode: VoteResultMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoteOption {
|
||||||
|
text: string;
|
||||||
|
count: number;
|
||||||
|
voters: string[];
|
||||||
|
percentage?: number; // 用于OBS显示
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResponseVoteSession {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
options: VoteOption[];
|
||||||
|
startTime: number;
|
||||||
|
endTime?: number;
|
||||||
|
isActive: boolean;
|
||||||
|
totalVotes: number;
|
||||||
|
creator?: UserBasicInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestCreateBulletVote {
|
||||||
|
title: string;
|
||||||
|
options: string[];
|
||||||
|
allowMultipleVotes: boolean;
|
||||||
|
durationSeconds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoteOBSData {
|
||||||
|
title: string;
|
||||||
|
options: VoteOption[];
|
||||||
|
totalVotes: number;
|
||||||
|
showResults: boolean;
|
||||||
|
isEnding: boolean;
|
||||||
|
backgroundImage?: string;
|
||||||
|
backgroundColor: string;
|
||||||
|
textColor: string;
|
||||||
|
optionColor: string;
|
||||||
|
roundedCorners: boolean;
|
||||||
|
displayPosition: string;
|
||||||
|
}
|
||||||
1
src/components.d.ts
vendored
1
src/components.d.ts
vendored
@@ -28,6 +28,7 @@ declare module 'vue' {
|
|||||||
NGridItem: typeof import('naive-ui')['NGridItem']
|
NGridItem: typeof import('naive-ui')['NGridItem']
|
||||||
NIcon: typeof import('naive-ui')['NIcon']
|
NIcon: typeof import('naive-ui')['NIcon']
|
||||||
NImage: typeof import('naive-ui')['NImage']
|
NImage: typeof import('naive-ui')['NImage']
|
||||||
|
NInputGroupLabel: typeof import('naive-ui')['NInputGroupLabel']
|
||||||
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
|
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
|
||||||
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||||
NSpace: typeof import('naive-ui')['NSpace']
|
NSpace: typeof import('naive-ui')['NSpace']
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ export const ANALYZE_API_URL = BASE_API_URL + 'analyze/';
|
|||||||
export const CHECKIN_API_URL = BASE_API_URL + 'checkin/';
|
export const CHECKIN_API_URL = BASE_API_URL + 'checkin/';
|
||||||
export const USER_CONFIG_API_URL = BASE_API_URL + 'user-config/';
|
export const USER_CONFIG_API_URL = BASE_API_URL + 'user-config/';
|
||||||
export const FILE_API_URL = BASE_API_URL + 'files/';
|
export const FILE_API_URL = BASE_API_URL + 'files/';
|
||||||
|
export const VOTE_API_URL = BASE_API_URL + 'vote/';
|
||||||
|
|
||||||
export type TemplateMapType = {
|
export type TemplateMapType = {
|
||||||
[key: string]: {
|
[key: string]: {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -144,6 +144,16 @@ export default //管理页面
|
|||||||
isNew: true
|
isNew: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'vote',
|
||||||
|
name: 'manage-danmakuVote',
|
||||||
|
component: () => import('@/views/open_live/DanmakuVote.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '弹幕投票',
|
||||||
|
keepAlive: true,
|
||||||
|
danmaku: true
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'live',
|
path: 'live',
|
||||||
name: 'manage-live',
|
name: 'manage-live',
|
||||||
|
|||||||
@@ -74,6 +74,15 @@ export default {
|
|||||||
title: '弹幕姬',
|
title: '弹幕姬',
|
||||||
forceReload: true,
|
forceReload: true,
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'danmaku-vote',
|
||||||
|
name: 'obs-danmaku-vote',
|
||||||
|
component: () => import('@/views/obs/DanmakuVoteOBS.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '弹幕投票',
|
||||||
|
forceReload: true,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
import { cookie, useAccount } from '@/api/account';
|
import { cookie, useAccount } from '@/api/account';
|
||||||
import { getEventType, recordEvent, streamingInfo } from '@/client/data/info';
|
import { getEventType, recordEvent, streamingInfo } from '@/client/data/info';
|
||||||
|
import { QueryBiliAPI } from '@/client/data/utils';
|
||||||
import { BASE_HUB_URL, isDev, isTauri } from '@/data/constants';
|
import { BASE_HUB_URL, isDev, isTauri } from '@/data/constants';
|
||||||
import BaseDanmakuClient from '@/data/DanmakuClients/BaseDanmakuClient';
|
import { DirectClientAuthInfo } from '@/data/DanmakuClients/DirectClient';
|
||||||
import DirectClient, { DirectClientAuthInfo } from '@/data/DanmakuClients/DirectClient';
|
|
||||||
import OpenLiveClient from '@/data/DanmakuClients/OpenLiveClient';
|
|
||||||
import * as signalR from '@microsoft/signalr';
|
import * as signalR from '@microsoft/signalr';
|
||||||
import * as msgpack from '@microsoft/signalr-protocol-msgpack';
|
import * as msgpack from '@microsoft/signalr-protocol-msgpack';
|
||||||
|
import { ZstdCodec, ZstdInit } from '@oneidentity/zstd-js/wasm';
|
||||||
|
import { platform, version } from '@tauri-apps/plugin-os';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { computed, ref, shallowRef } from 'vue'; // shallowRef 用于非深度响应对象
|
import { computed, ref, shallowRef } from 'vue'; // shallowRef 用于非深度响应对象
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { useWebRTC } from './useRTC';
|
import { useWebRTC } from './useRTC';
|
||||||
import { QueryBiliAPI } from '@/client/data/utils';
|
|
||||||
import { platform, type, version } from '@tauri-apps/plugin-os';
|
|
||||||
import { ZstdCodec, ZstdInit } from '@oneidentity/zstd-js/wasm';
|
|
||||||
|
|
||||||
|
import { onReceivedNotification } from '@/client/data/notification';
|
||||||
import { encode } from "@msgpack/msgpack";
|
import { encode } from "@msgpack/msgpack";
|
||||||
import { getVersion } from '@tauri-apps/api/app';
|
import { getVersion } from '@tauri-apps/api/app';
|
||||||
import { onReceivedNotification } from '@/client/data/notification';
|
|
||||||
import { useDanmakuClient } from './useDanmakuClient';
|
import { useDanmakuClient } from './useDanmakuClient';
|
||||||
|
|
||||||
export const useWebFetcher = defineStore('WebFetcher', () => {
|
export const useWebFetcher = defineStore('WebFetcher', () => {
|
||||||
@@ -335,6 +333,12 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
|
|||||||
Success: true,
|
Success: true,
|
||||||
Data: data
|
Data: data
|
||||||
} as ResponseFetchRequestData;
|
} as ResponseFetchRequestData;
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
Message: '请求失败: ' + result.statusText,
|
||||||
|
Success: false,
|
||||||
|
Data: ''
|
||||||
|
} as ResponseFetchRequestData;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ import { CodeOutline, ServerOutline, HeartOutline, LogoGithub } from '@vicons/io
|
|||||||
>
|
>
|
||||||
<NButton
|
<NButton
|
||||||
tag="a"
|
tag="a"
|
||||||
href="hhttps://microsoft.github.io/garnet/"
|
href="https://microsoft.github.io/garnet/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
text
|
text
|
||||||
style="padding: 0; color: inherit;"
|
style="padding: 0; color: inherit;"
|
||||||
|
|||||||
@@ -329,6 +329,16 @@ const menuOptions = computed(() => {
|
|||||||
icon: renderIcon(TabletSpeaker24Filled),
|
icon: renderIcon(TabletSpeaker24Filled),
|
||||||
disabled: !isBiliVerified.value,
|
disabled: !isBiliVerified.value,
|
||||||
},
|
},
|
||||||
|
/*{
|
||||||
|
label: () => !isBiliVerified.value ? '弹幕投票' : h(
|
||||||
|
RouterLink,
|
||||||
|
{ to: { name: 'manage-danmakuVote' } },
|
||||||
|
{ default: () => '弹幕投票' },
|
||||||
|
),
|
||||||
|
key: 'manage-danmakuVote',
|
||||||
|
icon: renderIcon(Chat24Filled),
|
||||||
|
disabled: !isBiliVerified.value,
|
||||||
|
},*/
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,18 +1,28 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useAccount } from '@/api/account'
|
||||||
|
import { useWebFetcher } from '@/store/useWebFetcher'
|
||||||
import { NSpin } from 'naive-ui'
|
import { NSpin } from 'naive-ui'
|
||||||
import { onMounted, onUnmounted, ref } from 'vue'
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
const timer = ref<any>()
|
const timer = ref<any>()
|
||||||
const visible = ref(true)
|
const visible = ref(true)
|
||||||
const active = ref(true)
|
const active = ref(true)
|
||||||
|
const webfetcher = useWebFetcher()
|
||||||
|
const accountInfo = useAccount()
|
||||||
|
|
||||||
|
const code = accountInfo.value.id ? accountInfo.value.biliAuthCode : window.$route.query.code?.toString()
|
||||||
|
|
||||||
const originalBackgroundColor = ref('')
|
const originalBackgroundColor = ref('')
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
timer.value = setInterval(() => {
|
timer.value = setInterval(() => {
|
||||||
if (!visible.value || !active.value) return
|
if (!visible.value || !active.value) return
|
||||||
window.$mitt.emit('onOBSComponentUpdate')
|
window.$mitt.emit('onOBSComponentUpdate')
|
||||||
}, 1000)
|
}, 1000)
|
||||||
|
|
||||||
|
if (accountInfo.value.id) {
|
||||||
|
await webfetcher.Start()
|
||||||
|
}
|
||||||
|
|
||||||
//@ts-expect-error 这里获取不了
|
//@ts-expect-error 这里获取不了
|
||||||
if (window.obsstudio) {
|
if (window.obsstudio) {
|
||||||
//@ts-expect-error 这里获取不了
|
//@ts-expect-error 这里获取不了
|
||||||
@@ -51,6 +61,7 @@ onUnmounted(() => {
|
|||||||
:is="Component"
|
:is="Component"
|
||||||
:active
|
:active
|
||||||
:visible
|
:visible
|
||||||
|
:code="code"
|
||||||
/>
|
/>
|
||||||
<template #fallback>
|
<template #fallback>
|
||||||
<NSpin show />
|
<NSpin show />
|
||||||
|
|||||||
@@ -2,15 +2,15 @@
|
|||||||
import { copyToClipboard, downloadImage } from '@/Utils'
|
import { copyToClipboard, downloadImage } from '@/Utils'
|
||||||
import { DisableFunction, EnableFunction, SaveAccountSettings, SaveSetting, useAccount } from '@/api/account'
|
import { DisableFunction, EnableFunction, SaveAccountSettings, SaveSetting, useAccount } from '@/api/account'
|
||||||
import { FunctionTypes, QAInfo, Setting_QuestionDisplay } from '@/api/api-models'
|
import { FunctionTypes, QAInfo, Setting_QuestionDisplay } from '@/api/api-models'
|
||||||
import { CN_HOST, CURRENT_HOST } from '@/data/constants'
|
|
||||||
import router from '@/router'
|
|
||||||
import QuestionItem from '@/components/QuestionItem.vue'
|
import QuestionItem from '@/components/QuestionItem.vue'
|
||||||
import QuestionItems from '@/components/QuestionItems.vue'
|
import QuestionItems from '@/components/QuestionItems.vue'
|
||||||
import QuestionDisplayCard from './QuestionDisplayCard.vue'
|
import { CURRENT_HOST } from '@/data/constants'
|
||||||
|
import router from '@/router'
|
||||||
import { useQuestionBox } from '@/store/useQuestionBox'
|
import { useQuestionBox } from '@/store/useQuestionBox'
|
||||||
import { Delete24Filled, Delete24Regular, Eye24Filled, EyeOff24Filled, Info24Filled } from '@vicons/fluent'
|
import { Delete24Filled, Delete24Regular, Eye24Filled, EyeOff24Filled, Info24Filled } from '@vicons/fluent'
|
||||||
import { Heart, HeartOutline, TrashBin } from '@vicons/ionicons5'
|
import { Heart, HeartOutline, TrashBin } from '@vicons/ionicons5'
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
|
import QuestionDisplayCard from './QuestionDisplayCard.vue'
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { saveAs } from 'file-saver'
|
import { saveAs } from 'file-saver'
|
||||||
import html2canvas from 'html2canvas'
|
import html2canvas from 'html2canvas'
|
||||||
@@ -89,7 +89,7 @@ const setting = computed({
|
|||||||
// 分享链接 (统一 Host, 根据选择的标签附加参数)
|
// 分享链接 (统一 Host, 根据选择的标签附加参数)
|
||||||
const shareUrlWithTag = (tag: string | null) => {
|
const shareUrlWithTag = (tag: string | null) => {
|
||||||
const base = `${CURRENT_HOST}@${accountInfo.value?.name}/question-box`
|
const base = `${CURRENT_HOST}@${accountInfo.value?.name}/question-box`
|
||||||
return tag ? `${base}?tag=${encodeURIComponent(tag)}` : base
|
return tag ? `${base}?tag=${tag}` : base
|
||||||
}
|
}
|
||||||
|
|
||||||
// 主链接区域显示的链接
|
// 主链接区域显示的链接
|
||||||
|
|||||||
360
src/views/obs/DanmakuVoteOBS.vue
Normal file
360
src/views/obs/DanmakuVoteOBS.vue
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { EventModel, VoteConfig, VoteOBSData, VoteOption } from '@/api/api-models'
|
||||||
|
import { QueryGetAPI } from '@/api/query'
|
||||||
|
import { VOTE_API_URL } from '@/data/constants'
|
||||||
|
import { useDanmakuClient } from '@/store/useDanmakuClient'
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { clearInterval, setInterval } from 'worker-timers'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
roomId?: number
|
||||||
|
code?: string
|
||||||
|
active?: boolean
|
||||||
|
visible?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const client = useDanmakuClient()
|
||||||
|
const voteData = ref<VoteOBSData | null>(null)
|
||||||
|
const fetchIntervalId = ref<number | null>(null)
|
||||||
|
const config = ref<VoteConfig | null>(null)
|
||||||
|
const isLoading = ref(true)
|
||||||
|
|
||||||
|
// 可见性检测
|
||||||
|
const isVisible = computed(() => props.visible !== false)
|
||||||
|
const isActive = computed(() => props.active !== false)
|
||||||
|
|
||||||
|
// 从后端获取投票数据
|
||||||
|
async function fetchVoteData() {
|
||||||
|
try {
|
||||||
|
const userId = getUserIdFromUrl()
|
||||||
|
if (!userId) return
|
||||||
|
|
||||||
|
const result = await QueryGetAPI<VoteOBSData>(`${VOTE_API_URL}obs-data`, { user: userId })
|
||||||
|
|
||||||
|
if (result.code === 0 && result.data) {
|
||||||
|
voteData.value = result.data
|
||||||
|
// 更新每个选项的百分比
|
||||||
|
if (voteData.value && voteData.value.options && voteData.value.totalVotes > 0) {
|
||||||
|
voteData.value.options.forEach(option => {
|
||||||
|
option.percentage = calculatePercentage(option.count, voteData.value!.totalVotes)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (voteData.value && !result.data) {
|
||||||
|
// 投票结束或无投票
|
||||||
|
voteData.value = null
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取投票数据失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理接收到的弹幕
|
||||||
|
function processDanmaku(event: EventModel) {
|
||||||
|
// 当使用API获取投票数据时,此处不需要处理投票逻辑
|
||||||
|
// 仅用于获取房间连接
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从URL获取用户ID
|
||||||
|
function getUserIdFromUrl(): string | null {
|
||||||
|
const hash = route.query.hash as string
|
||||||
|
if (hash) {
|
||||||
|
const parts = hash.split('_')
|
||||||
|
if (parts.length === 2) {
|
||||||
|
return parts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return route.query.user as string || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算百分比
|
||||||
|
function calculatePercentage(count: number, total: number): number {
|
||||||
|
if (total === 0) return 0
|
||||||
|
return Math.round((count / total) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取投票配置
|
||||||
|
async function fetchVoteConfig() {
|
||||||
|
try {
|
||||||
|
const userId = getUserIdFromUrl()
|
||||||
|
if (!userId) return
|
||||||
|
|
||||||
|
const result = await QueryGetAPI<VoteConfig>(`${VOTE_API_URL}get-config`, { user: userId })
|
||||||
|
|
||||||
|
if (result.code === 0 && result.data) {
|
||||||
|
config.value = result.data
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取投票配置失败:', error)
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置投票数据轮询
|
||||||
|
function setupPolling() {
|
||||||
|
if (fetchIntervalId.value) {
|
||||||
|
clearInterval(fetchIntervalId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每秒获取一次投票数据
|
||||||
|
fetchVoteData()
|
||||||
|
fetchIntervalId.value = setInterval(() => {
|
||||||
|
fetchVoteData()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取某个选项占总票数的百分比
|
||||||
|
function getPercentage(option: VoteOption): number {
|
||||||
|
if (!voteData.value || voteData.value.totalVotes === 0) return 0
|
||||||
|
return option.percentage || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主题相关
|
||||||
|
const theme = computed(() => {
|
||||||
|
if (route.query.theme && typeof route.query.theme === 'string') {
|
||||||
|
return route.query.theme
|
||||||
|
}
|
||||||
|
return 'default'
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// 设置房间ID和代码并连接
|
||||||
|
const roomId = props.roomId || Number(route.query.roomId)
|
||||||
|
const code = props.code || route.query.code
|
||||||
|
|
||||||
|
if (roomId && code) {
|
||||||
|
await client.initOpenlive()
|
||||||
|
// 监听弹幕事件 (仅用于保持连接)
|
||||||
|
client.onEvent('danmaku', processDanmaku)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取投票配置和投票数据
|
||||||
|
await fetchVoteConfig()
|
||||||
|
setupPolling()
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
client.dispose()
|
||||||
|
if (fetchIntervalId.value) {
|
||||||
|
clearInterval(fetchIntervalId.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="voteData && isVisible && isActive"
|
||||||
|
class="danmaku-vote-obs"
|
||||||
|
:class="[
|
||||||
|
`theme-${theme}`,
|
||||||
|
`position-${voteData.displayPosition || 'right'}`,
|
||||||
|
{ 'rounded': voteData.roundedCorners }
|
||||||
|
]"
|
||||||
|
:style="{
|
||||||
|
'--bg-color': voteData.backgroundColor || '#1e1e2e',
|
||||||
|
'--text-color': voteData.textColor || '#ffffff',
|
||||||
|
'--option-color': voteData.optionColor || '#89b4fa',
|
||||||
|
'--bg-image': voteData.backgroundImage ? `url(${voteData.backgroundImage})` : 'none'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="vote-container">
|
||||||
|
<div class="vote-header">
|
||||||
|
<div class="vote-title">
|
||||||
|
{{ voteData.title }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vote-stats">
|
||||||
|
总票数: <span class="vote-count">{{ voteData.totalVotes }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vote-options">
|
||||||
|
<div
|
||||||
|
v-for="(option, index) in voteData.options"
|
||||||
|
:key="index"
|
||||||
|
class="vote-option"
|
||||||
|
>
|
||||||
|
<div class="option-header">
|
||||||
|
<div class="option-name">
|
||||||
|
{{ index + 1 }}. {{ option.text }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="voteData.showResults"
|
||||||
|
class="option-count-wrapper"
|
||||||
|
>
|
||||||
|
<span class="option-count">{{ option.count }}</span>
|
||||||
|
<span class="option-percent">{{ getPercentage(option) }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="voteData.showResults"
|
||||||
|
class="progress-wrapper"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="progress-bar"
|
||||||
|
:style="`width: ${getPercentage(option)}%`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="isLoading"
|
||||||
|
class="danmaku-vote-loading"
|
||||||
|
>
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.danmaku-vote-obs {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: "Microsoft YaHei", sans-serif;
|
||||||
|
color: var(--text-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-vote-loading {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: "Microsoft YaHei", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-container {
|
||||||
|
width: 90%;
|
||||||
|
max-width: 600px;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
background-image: var(--bg-image);
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-vote-obs.rounded .vote-container {
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-stats {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-count {
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--option-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vote-option {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-name {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-count-wrapper {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-count {
|
||||||
|
background-color: var(--option-color);
|
||||||
|
color: var(--bg-color);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-percent {
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
color: var(--text-color);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-wrapper {
|
||||||
|
height: 8px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--option-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 位置样式 */
|
||||||
|
.position-right {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-left {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-top {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-bottom {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主题样式 */
|
||||||
|
.theme-transparent .vote-container {
|
||||||
|
background-color: rgba(0, 0, 0, 0.6);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -526,6 +526,7 @@ onUnmounted(() => {
|
|||||||
background-color: rgba(0, 0, 0, 0.2);
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-list-item-user-name {
|
.queue-list-item-user-name {
|
||||||
@@ -534,8 +535,17 @@ onUnmounted(() => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
max-width: 50%;
|
max-width: 60%;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 只有在小屏幕/容器较窄时才允许换行 */
|
||||||
|
@media (max-width: 300px) {
|
||||||
|
.queue-list-item-user-name {
|
||||||
|
white-space: normal;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-list-item-payment {
|
.queue-list-item-payment {
|
||||||
@@ -705,6 +715,9 @@ onUnmounted(() => {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: white;
|
color: white;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes animated-border {
|
@keyframes animated-border {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
import { useElementSize } from '@vueuse/core'
|
import { useElementSize } from '@vueuse/core'
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { Vue3Marquee } from 'vue3-marquee'
|
|
||||||
import { NDivider, NEmpty } from 'naive-ui'
|
import { NDivider, NEmpty } from 'naive-ui'
|
||||||
import { useLiveRequestData } from './useLiveRequestData'
|
import { useLiveRequestData } from './useLiveRequestData'
|
||||||
|
|
||||||
@@ -14,6 +13,7 @@ const props = defineProps<{
|
|||||||
id?: number,
|
id?: number,
|
||||||
active?: boolean,
|
active?: boolean,
|
||||||
visible?: boolean,
|
visible?: boolean,
|
||||||
|
speedMultiplier?: number,
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -21,6 +21,15 @@ const currentId = computed(() => {
|
|||||||
return props.id ?? route.query.id
|
return props.id ?? route.query.id
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const speedMultiplier = computed(() => {
|
||||||
|
if (props.speedMultiplier !== undefined && props.speedMultiplier > 0) {
|
||||||
|
return props.speedMultiplier
|
||||||
|
}
|
||||||
|
const speedParam = route.query.speed
|
||||||
|
const speed = parseFloat(speedParam?.toString() ?? '1')
|
||||||
|
return isNaN(speed) || speed <= 0 ? 1 : speed
|
||||||
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
songs,
|
songs,
|
||||||
settings,
|
settings,
|
||||||
@@ -37,10 +46,37 @@ const listContainerRef = ref()
|
|||||||
const { height, width } = useElementSize(listContainerRef)
|
const { height, width } = useElementSize(listContainerRef)
|
||||||
const itemHeight = 40
|
const itemHeight = 40
|
||||||
|
|
||||||
const isMoreThanContainer = computed(() => {
|
const listInnerRef = ref<HTMLElement | null>(null)
|
||||||
return activeSongs.value.length * itemHeight > height.value
|
const { height: innerListHeight } = useElementSize(listInnerRef)
|
||||||
|
|
||||||
|
const itemMarginBottom = 5
|
||||||
|
const totalContentHeightWithLastMargin = computed(() => {
|
||||||
|
const count = activeSongs.value.length
|
||||||
|
if (count === 0 || innerListHeight.value <= 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return innerListHeight.value + itemMarginBottom
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isMoreThanContainer = computed(() => {
|
||||||
|
return totalContentHeightWithLastMargin.value > height.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const animationTranslateY = computed(() => {
|
||||||
|
if (!isMoreThanContainer.value || height.value <= 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return height.value - totalContentHeightWithLastMargin.value
|
||||||
|
})
|
||||||
|
const animationTranslateYCss = computed(() => `${animationTranslateY.value}px`)
|
||||||
|
|
||||||
|
const animationDuration = computed(() => {
|
||||||
|
const baseDuration = activeSongs.value.length * 1
|
||||||
|
const adjustedDuration = baseDuration / speedMultiplier.value
|
||||||
|
return Math.max(adjustedDuration, 1)
|
||||||
|
})
|
||||||
|
const animationDurationCss = computed(() => `${animationDuration.value}s`)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
update()
|
update()
|
||||||
initRTC()
|
initRTC()
|
||||||
@@ -102,13 +138,11 @@ onUnmounted(() => {
|
|||||||
class="live-request-content"
|
class="live-request-content"
|
||||||
>
|
>
|
||||||
<template v-if="activeSongs.length > 0">
|
<template v-if="activeSongs.length > 0">
|
||||||
<Vue3Marquee
|
<div
|
||||||
:key="key"
|
ref="listInnerRef"
|
||||||
class="live-request-list"
|
class="live-request-list"
|
||||||
vertical
|
:class="{ animating: isMoreThanContainer }"
|
||||||
:duration="20"
|
:style="`width: ${width}px;`"
|
||||||
:pause="!isMoreThanContainer"
|
|
||||||
:style="`height: ${height}px;width: ${width}px;`"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="(song, index) in activeSongs"
|
v-for="(song, index) in activeSongs"
|
||||||
@@ -116,7 +150,6 @@ onUnmounted(() => {
|
|||||||
class="live-request-list-item"
|
class="live-request-list-item"
|
||||||
:from="song.from as number"
|
:from="song.from as number"
|
||||||
:status="song.status as number"
|
:status="song.status as number"
|
||||||
:style="`height: ${itemHeight}px`"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="live-request-list-item-index"
|
class="live-request-list-item-index"
|
||||||
@@ -125,28 +158,23 @@ onUnmounted(() => {
|
|||||||
{{ index + 1 }}
|
{{ index + 1 }}
|
||||||
</div>
|
</div>
|
||||||
<div class="live-request-list-item-song-name">
|
<div class="live-request-list-item-song-name">
|
||||||
{{ song.songName }}
|
{{ song.songName || '未知歌曲' }}
|
||||||
</div>
|
</div>
|
||||||
<p
|
<div
|
||||||
v-if="settings.showUserName"
|
v-if="settings.showUserName"
|
||||||
class="live-request-list-item-name"
|
class="live-request-list-item-name"
|
||||||
>
|
>
|
||||||
{{ song.from == SongRequestFrom.Manual ? '主播添加' : song.user?.name }}
|
{{ song.from == SongRequestFrom.Manual ? '主播添加' : song.user?.name || '未知用户' }}
|
||||||
</p>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="settings.showFanMadelInfo"
|
v-if="settings.showFanMadelInfo && (song.user?.fans_medal_level ?? 0) > 0"
|
||||||
class="live-request-list-item-level"
|
class="live-request-list-item-level"
|
||||||
:has-level="(song.user?.fans_medal_level ?? 0) > 0"
|
:has-level="(song.user?.fans_medal_level ?? 0) > 0"
|
||||||
>
|
>
|
||||||
{{ `${song.user?.fans_medal_name} ${song.user?.fans_medal_level}` }}
|
{{ `${song.user?.fans_medal_name || ''} ${song.user?.fans_medal_level || ''}` }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NDivider
|
</div>
|
||||||
v-if="isMoreThanContainer"
|
|
||||||
class="live-request-footer-divider"
|
|
||||||
style="margin: 10px 0 10px 0"
|
|
||||||
/>
|
|
||||||
</Vue3Marquee>
|
|
||||||
</template>
|
</template>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
@@ -163,56 +191,82 @@ onUnmounted(() => {
|
|||||||
ref="footerRef"
|
ref="footerRef"
|
||||||
class="live-request-footer"
|
class="live-request-footer"
|
||||||
>
|
>
|
||||||
<Vue3Marquee
|
<div class="live-request-footer-info">
|
||||||
:key="key"
|
<div class="live-request-footer-tags">
|
||||||
ref="footerListRef"
|
<div
|
||||||
class="live-request-footer-marquee"
|
class="live-request-footer-tag"
|
||||||
:duration="10"
|
type="prefix"
|
||||||
animate-on-overflow-only
|
>
|
||||||
>
|
<span class="tag-label">前缀</span>
|
||||||
<span
|
<span class="tag-value">{{ settings.orderPrefix }}</span>
|
||||||
class="live-request-tag"
|
|
||||||
type="prefix"
|
|
||||||
>
|
|
||||||
<div class="live-request-tag-key">前缀</div>
|
|
||||||
<div class="live-request-tag-value">
|
|
||||||
{{ settings.orderPrefix }}
|
|
||||||
</div>
|
</div>
|
||||||
</span>
|
<div
|
||||||
<span
|
class="live-request-footer-tag"
|
||||||
class="live-request-tag"
|
type="allow"
|
||||||
type="prefix"
|
>
|
||||||
>
|
<span class="tag-label">允许</span>
|
||||||
<div class="live-request-tag-key">允许</div>
|
<span class="tag-value">{{ settings.allowAllDanmaku ? '所有弹幕' : allowGuardTypes.length > 0 ? allowGuardTypes.join('/') : '无' }}</span>
|
||||||
<div class="live-request-tag-value">
|
|
||||||
{{ settings.allowAllDanmaku ? '所有弹幕' : allowGuardTypes.length > 0 ? allowGuardTypes.join(',') : '无' }}
|
|
||||||
</div>
|
</div>
|
||||||
</span>
|
<div
|
||||||
<span
|
class="live-request-footer-tag"
|
||||||
class="live-request-tag"
|
type="sc"
|
||||||
type="sc"
|
>
|
||||||
>
|
<span class="tag-label">SC点歌</span>
|
||||||
<div class="live-request-tag-key">SC点歌</div>
|
<span class="tag-value">{{ settings.allowSC ? '> ¥' + settings.scMinPrice : '不允许' }}</span>
|
||||||
<div class="live-request-tag-value">
|
|
||||||
{{ settings.allowSC ? '> ¥' + settings.scMinPrice : '不允许' }}
|
|
||||||
</div>
|
</div>
|
||||||
</span>
|
<div
|
||||||
<span
|
class="live-request-footer-tag"
|
||||||
class="live-request-tag"
|
type="medal"
|
||||||
type="fan-madel"
|
>
|
||||||
>
|
<span class="tag-label">粉丝牌</span>
|
||||||
<div class="live-request-tag-key">粉丝牌</div>
|
<span class="tag-value">
|
||||||
<div class="live-request-tag-value">
|
{{
|
||||||
{{
|
settings.needWearFanMedal
|
||||||
settings.needWearFanMedal
|
? settings.fanMedalMinLevel > 0
|
||||||
? settings.fanMedalMinLevel > 0
|
? '> ' + settings.fanMedalMinLevel
|
||||||
? '> ' + settings.fanMedalMinLevel
|
: '佩戴'
|
||||||
: '佩戴'
|
: '无需'
|
||||||
: '无需'
|
}}
|
||||||
}}
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</span>
|
<div
|
||||||
</Vue3Marquee>
|
class="live-request-footer-tag"
|
||||||
|
type="prefix"
|
||||||
|
>
|
||||||
|
<span class="tag-label">前缀</span>
|
||||||
|
<span class="tag-value">{{ settings.orderPrefix }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="live-request-footer-tag"
|
||||||
|
type="allow"
|
||||||
|
>
|
||||||
|
<span class="tag-label">允许</span>
|
||||||
|
<span class="tag-value">{{ settings.allowAllDanmaku ? '所有弹幕' : allowGuardTypes.length > 0 ? allowGuardTypes.join('/') : '无' }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="live-request-footer-tag"
|
||||||
|
type="sc"
|
||||||
|
>
|
||||||
|
<span class="tag-label">SC点歌</span>
|
||||||
|
<span class="tag-value">{{ settings.allowSC ? '> ¥' + settings.scMinPrice : '不允许' }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="live-request-footer-tag"
|
||||||
|
type="medal"
|
||||||
|
>
|
||||||
|
<span class="tag-label">粉丝牌</span>
|
||||||
|
<span class="tag-value">
|
||||||
|
{{
|
||||||
|
settings.needWearFanMedal
|
||||||
|
? settings.fanMedalMinLevel > 0
|
||||||
|
? '> ' + settings.fanMedalMinLevel
|
||||||
|
: '佩戴'
|
||||||
|
: '无需'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -327,33 +381,72 @@ onUnmounted(() => {
|
|||||||
.live-request-content {
|
.live-request-content {
|
||||||
background-color: #0f0f0f4f;
|
background-color: #0f0f0f4f;
|
||||||
margin: 10px;
|
margin: 10px;
|
||||||
padding: 10px;
|
padding: 8px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
overflow-x: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.marquee {
|
.live-request-list {
|
||||||
justify-items: left;
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes vertical-ping-pong {
|
||||||
|
0% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(v-bind(animationTranslateYCss));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-request-list.animating {
|
||||||
|
animation-name: vertical-ping-pong;
|
||||||
|
animation-duration: v-bind(animationDurationCss);
|
||||||
|
animation-timing-function: ease-in-out;
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
animation-direction: alternate;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-request-list.animating:hover {
|
||||||
|
animation-play-state: paused;
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-request-list-item {
|
.live-request-list-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
position: relative;
|
position: relative;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: left;
|
justify-content: left;
|
||||||
gap: 10px;
|
gap: 5px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
min-height: 36px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-request-list-item-song-name {
|
.live-request-list-item-song-name {
|
||||||
font-size: 18px;
|
font-size: 16px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
max-width: 80%;
|
max-width: 60%;
|
||||||
|
flex-grow: 1;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 只有在小屏幕/容器较窄时才允许换行 */
|
||||||
|
@media (max-width: 300px) {
|
||||||
|
.live-request-list-item-song-name {
|
||||||
|
white-space: normal;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 手动添加 */
|
/* 手动添加 */
|
||||||
@@ -378,6 +471,11 @@ onUnmounted(() => {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 50px;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-request-list-item-index {
|
.live-request-list-item-index {
|
||||||
@@ -394,10 +492,10 @@ onUnmounted(() => {
|
|||||||
.live-request-list-item-level {
|
.live-request-list-item-level {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
padding: 2px;
|
padding: 2px 6px;
|
||||||
min-width: 15px;
|
min-width: 15px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
background-color: #0f0f0f48;
|
background-color: rgba(0, 0, 0, 0.3);
|
||||||
color: rgba(204, 204, 204, 0.993);
|
color: rgba(204, 204, 204, 0.993);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
@@ -408,34 +506,95 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.live-request-footer {
|
.live-request-footer {
|
||||||
margin: 0 5px 5px 5px;
|
margin: 0 5px 5px 5px;
|
||||||
height: 60px;
|
background-color: rgba(0, 0, 0, 0.25);
|
||||||
border-radius: 5px;
|
border-radius: 8px;
|
||||||
background-color: #0f0f0f4f;
|
padding: 8px 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
height: auto;
|
||||||
|
min-height: 40px;
|
||||||
|
max-height: 60px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-request-tag {
|
.live-request-footer-info {
|
||||||
display: flex;
|
width: 100%;
|
||||||
margin: 5px 0 5px 5px;
|
overflow: hidden;
|
||||||
height: 40px;
|
position: relative;
|
||||||
border-radius: 3px;
|
}
|
||||||
background-color: #0f0f0f4f;
|
|
||||||
padding: 4px;
|
.live-request-footer-tags {
|
||||||
padding-right: 6px;
|
display: inline-flex;
|
||||||
display: flex;
|
flex-wrap: nowrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
animation: scrollTags 25s linear infinite;
|
||||||
|
padding-right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scrollTags {
|
||||||
|
0% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-request-footer-tags:hover {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-request-footer-tag {
|
||||||
|
display: inline-flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: left;
|
padding: 5px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.12);
|
||||||
|
min-width: max-content;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-request-tag-key {
|
.live-request-footer-tag[type="prefix"] {
|
||||||
font-style: italic;
|
background: linear-gradient(135deg, rgba(59, 130, 246, 0.12), rgba(37, 99, 235, 0.18));
|
||||||
color: rgb(211, 211, 211);
|
}
|
||||||
|
|
||||||
|
.live-request-footer-tag[type="allow"] {
|
||||||
|
background: linear-gradient(135deg, rgba(16, 185, 129, 0.12), rgba(5, 150, 105, 0.18));
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-request-footer-tag[type="sc"] {
|
||||||
|
background: linear-gradient(135deg, rgba(244, 114, 182, 0.12), rgba(219, 39, 119, 0.18));
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-request-footer-tag[type="medal"] {
|
||||||
|
background: linear-gradient(135deg, rgba(239, 68, 68, 0.12), rgba(220, 38, 38, 0.18));
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-request-footer-tag:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-label {
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.8;
|
||||||
|
color: #e5e7eb;
|
||||||
|
font-weight: normal;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-value {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
.live-request-tag-value {
|
line-height: 1.2;
|
||||||
font-size: 14px;
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-request-list-item-index[index='1'] {
|
.live-request-list-item-index[index='1'] {
|
||||||
|
|||||||
796
src/views/open_live/DanmakuVote.vue
Normal file
796
src/views/open_live/DanmakuVote.vue
Normal file
@@ -0,0 +1,796 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { copyToClipboard } from '@/Utils'
|
||||||
|
import { useAccount } from '@/api/account'
|
||||||
|
import { OpenLiveInfo, RequestCreateBulletVote, ResponseVoteSession, VoteConfig } from '@/api/api-models'
|
||||||
|
import { QueryGetAPI, QueryPostAPI } from '@/api/query'
|
||||||
|
import { VOTE_API_URL } from '@/data/constants'
|
||||||
|
import { useDanmakuClient } from '@/store/useDanmakuClient'
|
||||||
|
import { Add24Filled, Delete24Regular, Info24Filled, Pause24Regular, Play24Regular, Settings24Regular, ShareAndroid24Regular } from '@vicons/fluent'
|
||||||
|
import { useStorage } from '@vueuse/core'
|
||||||
|
import {
|
||||||
|
NAlert,
|
||||||
|
NButton,
|
||||||
|
NCard,
|
||||||
|
NCheckbox,
|
||||||
|
NColorPicker,
|
||||||
|
NDivider,
|
||||||
|
NEmpty,
|
||||||
|
NIcon,
|
||||||
|
NInput,
|
||||||
|
NInputGroup,
|
||||||
|
NInputGroupLabel,
|
||||||
|
NInputNumber,
|
||||||
|
NList,
|
||||||
|
NListItem,
|
||||||
|
NModal,
|
||||||
|
NProgress,
|
||||||
|
NRadio,
|
||||||
|
NRadioGroup,
|
||||||
|
NSelect,
|
||||||
|
NSpace,
|
||||||
|
NSpin,
|
||||||
|
NSwitch,
|
||||||
|
NTag,
|
||||||
|
NText,
|
||||||
|
NThing,
|
||||||
|
useMessage
|
||||||
|
} from 'naive-ui'
|
||||||
|
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { clearInterval, setInterval } from 'worker-timers'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
roomInfo?: OpenLiveInfo
|
||||||
|
code?: string | undefined
|
||||||
|
isOpenLive?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 账号信息
|
||||||
|
const accountInfo = useAccount()
|
||||||
|
const message = useMessage()
|
||||||
|
const route = useRoute()
|
||||||
|
const client = useDanmakuClient()
|
||||||
|
|
||||||
|
// 投票配置
|
||||||
|
const voteConfig = ref<VoteConfig>({
|
||||||
|
isEnabled: false,
|
||||||
|
showResults: true,
|
||||||
|
voteDurationSeconds: 60,
|
||||||
|
voteCommand: "投票",
|
||||||
|
voteEndCommand: "结束投票",
|
||||||
|
voteTitle: "投票",
|
||||||
|
allowMultipleOptions: false,
|
||||||
|
allowMultipleVotes: false,
|
||||||
|
allowCustomOptions: false,
|
||||||
|
logVotes: true,
|
||||||
|
defaultOptions: ["选项1", "选项2"],
|
||||||
|
backgroundColor: "#1e1e2e",
|
||||||
|
textColor: "#ffffff",
|
||||||
|
optionColor: "#89b4fa",
|
||||||
|
roundedCorners: true,
|
||||||
|
displayPosition: "right",
|
||||||
|
allowGiftVoting: false,
|
||||||
|
minGiftPrice: 1,
|
||||||
|
voteResultMode: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// 当前投票会话
|
||||||
|
const currentVote = ref<ResponseVoteSession | null>(null)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const showSettingsModal = ref(false)
|
||||||
|
const voteHistoryTab = ref<ResponseVoteSession[]>([])
|
||||||
|
|
||||||
|
// 创建投票相关
|
||||||
|
const newVoteTitle = ref('')
|
||||||
|
const newVoteOptions = ref<string[]>(['', ''])
|
||||||
|
const newVoteDuration = ref(60)
|
||||||
|
const newVoteAllowMultiple = ref(false)
|
||||||
|
|
||||||
|
// 添加新选项
|
||||||
|
function addOption() {
|
||||||
|
newVoteOptions.value.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除选项
|
||||||
|
function removeOption(index: number) {
|
||||||
|
newVoteOptions.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前用户的投票配置
|
||||||
|
async function fetchVoteConfig() {
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
const result = await QueryGetAPI<VoteConfig>(`${VOTE_API_URL}get-config`)
|
||||||
|
|
||||||
|
if (result.code === 0 && result.data) {
|
||||||
|
voteConfig.value = result.data
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取投票配置失败:', error)
|
||||||
|
message.error('获取投票配置失败')
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存投票配置
|
||||||
|
async function saveVoteConfig() {
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
const result = await QueryPostAPI<any>(`${VOTE_API_URL}save-config`, voteConfig.value)
|
||||||
|
|
||||||
|
if (result.code === 0) {
|
||||||
|
message.success('投票配置保存成功')
|
||||||
|
showSettingsModal.value = false
|
||||||
|
} else {
|
||||||
|
message.error(`保存失败: ${result.message}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存投票配置失败:', error)
|
||||||
|
message.error('保存投票配置失败')
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前活跃投票
|
||||||
|
async function fetchActiveVote() {
|
||||||
|
try {
|
||||||
|
const result = await QueryGetAPI<ResponseVoteSession>(`${VOTE_API_URL}get-active`)
|
||||||
|
|
||||||
|
if (result.code === 0) {
|
||||||
|
currentVote.value = result.data
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取当前投票失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取投票历史
|
||||||
|
async function fetchVoteHistory() {
|
||||||
|
try {
|
||||||
|
const result = await QueryGetAPI<ResponseVoteSession[]>(`${VOTE_API_URL}history`, { limit: 10, offset: 0 })
|
||||||
|
|
||||||
|
if (result.code === 0) {
|
||||||
|
voteHistoryTab.value = result.data
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取投票历史失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建投票
|
||||||
|
async function createVote() {
|
||||||
|
// 验证投票选项
|
||||||
|
if (!newVoteTitle.value) {
|
||||||
|
message.error('请输入投票标题')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredOptions = newVoteOptions.value.filter(opt => opt.trim() !== '')
|
||||||
|
if (filteredOptions.length < 2) {
|
||||||
|
message.error('至少需要两个投票选项')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const createVoteData: RequestCreateBulletVote = {
|
||||||
|
title: newVoteTitle.value,
|
||||||
|
options: filteredOptions,
|
||||||
|
allowMultipleVotes: newVoteAllowMultiple.value,
|
||||||
|
durationSeconds: newVoteDuration.value
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
const result = await QueryPostAPI<ResponseVoteSession>(`${VOTE_API_URL}create`, createVoteData)
|
||||||
|
|
||||||
|
if (result.code === 200) {
|
||||||
|
message.success('投票创建成功')
|
||||||
|
currentVote.value = result.data
|
||||||
|
resetCreateVoteForm()
|
||||||
|
} else {
|
||||||
|
message.error(`创建失败: ${result.message}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建投票失败:', error)
|
||||||
|
message.error('创建投票失败')
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置创建投票表单
|
||||||
|
function resetCreateVoteForm() {
|
||||||
|
newVoteTitle.value = ''
|
||||||
|
newVoteOptions.value = ['', '']
|
||||||
|
newVoteDuration.value = voteConfig.value.voteDurationSeconds
|
||||||
|
newVoteAllowMultiple.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 结束投票
|
||||||
|
async function endVote() {
|
||||||
|
if (!currentVote.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
const result = await QueryGetAPI<ResponseVoteSession>(`${VOTE_API_URL}end`, { id: currentVote.value.id })
|
||||||
|
|
||||||
|
if (result.code === 200) {
|
||||||
|
message.success('投票已结束')
|
||||||
|
currentVote.value = result.data
|
||||||
|
await fetchVoteHistory()
|
||||||
|
} else {
|
||||||
|
message.error(`结束失败: ${result.message}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('结束投票失败:', error)
|
||||||
|
message.error('结束投票失败')
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除投票
|
||||||
|
async function deleteVote(id: number) {
|
||||||
|
try {
|
||||||
|
isLoading.value = true
|
||||||
|
const result = await QueryGetAPI<any>(`${VOTE_API_URL}delete`, { id })
|
||||||
|
|
||||||
|
if (result.code === 200) {
|
||||||
|
message.success('投票已删除')
|
||||||
|
await fetchVoteHistory()
|
||||||
|
if (currentVote.value?.id === id) {
|
||||||
|
currentVote.value = null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
message.error(`删除失败: ${result.message}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除投票失败:', error)
|
||||||
|
message.error('删除投票失败')
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制OBS链接
|
||||||
|
function copyObsLink() {
|
||||||
|
const baseUrl = window.location.origin
|
||||||
|
const roomId = props.roomInfo?.anchor_info?.room_id || route.query.roomId
|
||||||
|
|
||||||
|
// 获取配置哈希
|
||||||
|
fetchVoteHash().then(hash => {
|
||||||
|
if (hash) {
|
||||||
|
const obsUrl = `${baseUrl}/obs/danmaku-vote?hash=${hash}`
|
||||||
|
copyToClipboard(obsUrl)
|
||||||
|
message.success('OBS链接已复制到剪贴板')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取投票配置哈希
|
||||||
|
async function fetchVoteHash(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const result = await QueryGetAPI<string>(`${VOTE_API_URL}get-hash`)
|
||||||
|
|
||||||
|
if (result.code === 0 && result.data) {
|
||||||
|
return result.data
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取投票哈希失败:', error)
|
||||||
|
message.error('获取投票哈希失败')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算每个选项的百分比
|
||||||
|
function calculatePercentage(count: number, totalVotes: number) {
|
||||||
|
if (totalVotes === 0) return 0
|
||||||
|
return Math.round((count / totalVotes) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载模板
|
||||||
|
function loadTemplate(template: {title: string, options: string[]}) {
|
||||||
|
newVoteTitle.value = template.title
|
||||||
|
newVoteOptions.value = [...template.options]
|
||||||
|
// 确保至少有两个选项
|
||||||
|
while (newVoteOptions.value.length < 2) {
|
||||||
|
newVoteOptions.value.push('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化和轮询
|
||||||
|
onMounted(async () => {
|
||||||
|
// 初始化弹幕客户端
|
||||||
|
await client.initOpenlive()
|
||||||
|
|
||||||
|
// 获取投票配置
|
||||||
|
await fetchVoteConfig()
|
||||||
|
|
||||||
|
// 获取当前活跃投票和历史记录
|
||||||
|
await fetchActiveVote()
|
||||||
|
await fetchVoteHistory()
|
||||||
|
|
||||||
|
// 设置轮询,每5秒获取一次当前投票数据
|
||||||
|
const pollInterval = setInterval(async () => {
|
||||||
|
await fetchActiveVote()
|
||||||
|
}, 5000)
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearInterval(pollInterval)
|
||||||
|
client.dispose()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监视配置变化,更新创建表单中的默认值
|
||||||
|
watch(() => voteConfig.value, (newConfig) => {
|
||||||
|
newVoteDuration.value = newConfig.voteDurationSeconds
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// 初始模板
|
||||||
|
const savedTemplates = useStorage<{title: string, options: string[]}[]>('DanmakuVoteTemplates', [])
|
||||||
|
const templateName = ref('')
|
||||||
|
|
||||||
|
// 保存模板
|
||||||
|
function saveTemplate() {
|
||||||
|
if (!templateName.value) {
|
||||||
|
message.error('请输入模板名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredOptions = newVoteOptions.value.filter(opt => opt.trim() !== '')
|
||||||
|
if (filteredOptions.length < 2) {
|
||||||
|
message.error('至少需要两个有效的投票选项')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
savedTemplates.value.push({
|
||||||
|
title: templateName.value,
|
||||||
|
options: filteredOptions
|
||||||
|
})
|
||||||
|
|
||||||
|
templateName.value = ''
|
||||||
|
message.success('模板保存成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除模板
|
||||||
|
function deleteTemplate(index: number) {
|
||||||
|
savedTemplates.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NSpace vertical>
|
||||||
|
<NAlert type="info">
|
||||||
|
<template #icon>
|
||||||
|
<NIcon>
|
||||||
|
<Info24Filled />
|
||||||
|
</NIcon>
|
||||||
|
</template>
|
||||||
|
弹幕投票功能让观众可以通过发送特定格式的弹幕参与投票。您可以自定义投票的标题、选项和外观。
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<NCard
|
||||||
|
title="投票控制"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<NSpin :show="isLoading">
|
||||||
|
<NSpace vertical>
|
||||||
|
<NSpace>
|
||||||
|
<NSwitch
|
||||||
|
v-model:value="voteConfig.isEnabled"
|
||||||
|
@update:value="saveVoteConfig"
|
||||||
|
>
|
||||||
|
<template #checked>
|
||||||
|
已启用
|
||||||
|
</template>
|
||||||
|
<template #unchecked>
|
||||||
|
已禁用
|
||||||
|
</template>
|
||||||
|
</NSwitch>
|
||||||
|
<NButton
|
||||||
|
secondary
|
||||||
|
@click="showSettingsModal = true"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<NIcon><Settings24Regular /></NIcon>
|
||||||
|
</template>
|
||||||
|
设置
|
||||||
|
</NButton>
|
||||||
|
<NButton
|
||||||
|
type="info"
|
||||||
|
@click="copyObsLink"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<NIcon><ShareAndroid24Regular /></NIcon>
|
||||||
|
</template>
|
||||||
|
复制OBS链接
|
||||||
|
</NButton>
|
||||||
|
</NSpace>
|
||||||
|
|
||||||
|
<NDivider />
|
||||||
|
|
||||||
|
<div v-if="!voteConfig.isEnabled">
|
||||||
|
<NAlert type="warning">
|
||||||
|
投票功能已禁用,请先在设置中启用功能。
|
||||||
|
</NAlert>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="currentVote && currentVote.isActive">
|
||||||
|
<NCard
|
||||||
|
title="进行中的投票"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<NSpace vertical>
|
||||||
|
<NSpace justify="space-between">
|
||||||
|
<NText
|
||||||
|
strong
|
||||||
|
style="font-size: 1.2em"
|
||||||
|
>
|
||||||
|
{{ currentVote.title }}
|
||||||
|
</NText>
|
||||||
|
<NButton
|
||||||
|
type="warning"
|
||||||
|
@click="endVote"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<NIcon><Pause24Regular /></NIcon>
|
||||||
|
</template>
|
||||||
|
结束投票
|
||||||
|
</NButton>
|
||||||
|
</NSpace>
|
||||||
|
|
||||||
|
<NText>总票数: {{ currentVote.totalVotes }}</NText>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(option, index) in currentVote.options"
|
||||||
|
:key="index"
|
||||||
|
>
|
||||||
|
<NSpace
|
||||||
|
vertical
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<NSpace justify="space-between">
|
||||||
|
<NText>{{ index + 1 }}. {{ option.text }}</NText>
|
||||||
|
<NSpace>
|
||||||
|
<NTag type="success">
|
||||||
|
{{ option.count }}票
|
||||||
|
</NTag>
|
||||||
|
<NTag>{{ calculatePercentage(option.count, currentVote.totalVotes) }}%</NTag>
|
||||||
|
</NSpace>
|
||||||
|
</NSpace>
|
||||||
|
<NProgress
|
||||||
|
type="line"
|
||||||
|
:percentage="calculatePercentage(option.count, currentVote.totalVotes)"
|
||||||
|
:height="12"
|
||||||
|
/>
|
||||||
|
</NSpace>
|
||||||
|
<NDivider v-if="index < currentVote.options.length - 1" />
|
||||||
|
</div>
|
||||||
|
</NSpace>
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<NSpace vertical>
|
||||||
|
<NInput
|
||||||
|
v-model:value="newVoteTitle"
|
||||||
|
placeholder="投票标题"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(option, index) in newVoteOptions"
|
||||||
|
:key="index"
|
||||||
|
>
|
||||||
|
<NInputGroup>
|
||||||
|
<NInputGroupLabel>{{ index + 1 }}</NInputGroupLabel>
|
||||||
|
<NInput
|
||||||
|
v-model:value="newVoteOptions[index]"
|
||||||
|
placeholder="选项内容"
|
||||||
|
/>
|
||||||
|
<NButton
|
||||||
|
quaternary
|
||||||
|
:disabled="newVoteOptions.length <= 2"
|
||||||
|
@click="removeOption(index)"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<NIcon><Delete24Regular /></NIcon>
|
||||||
|
</template>
|
||||||
|
</NButton>
|
||||||
|
</NInputGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NSpace>
|
||||||
|
<NButton @click="addOption">
|
||||||
|
<template #icon>
|
||||||
|
<NIcon><Add24Filled /></NIcon>
|
||||||
|
</template>
|
||||||
|
添加选项
|
||||||
|
</NButton>
|
||||||
|
</NSpace>
|
||||||
|
|
||||||
|
<NSpace>
|
||||||
|
<NInputGroup>
|
||||||
|
<NInputGroupLabel>持续时间</NInputGroupLabel>
|
||||||
|
<NInputNumber
|
||||||
|
v-model:value="newVoteDuration"
|
||||||
|
:min="10"
|
||||||
|
style="width: 100px"
|
||||||
|
>
|
||||||
|
<template #suffix>
|
||||||
|
秒
|
||||||
|
</template>
|
||||||
|
</NInputNumber>
|
||||||
|
</NInputGroup>
|
||||||
|
|
||||||
|
<NCheckbox v-model:checked="newVoteAllowMultiple">
|
||||||
|
允许重复投票
|
||||||
|
</NCheckbox>
|
||||||
|
</NSpace>
|
||||||
|
|
||||||
|
<NSpace justify="end">
|
||||||
|
<NButton
|
||||||
|
type="primary"
|
||||||
|
@click="createVote"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<NIcon><Play24Regular /></NIcon>
|
||||||
|
</template>
|
||||||
|
开始投票
|
||||||
|
</NButton>
|
||||||
|
</NSpace>
|
||||||
|
</NSpace>
|
||||||
|
</div>
|
||||||
|
</NSpace>
|
||||||
|
</NSpin>
|
||||||
|
</NCard>
|
||||||
|
|
||||||
|
<NCard
|
||||||
|
v-if="!currentVote?.isActive && voteConfig.isEnabled"
|
||||||
|
title="保存/加载模板"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<NSpace vertical>
|
||||||
|
<NSpace>
|
||||||
|
<NInput
|
||||||
|
v-model:value="templateName"
|
||||||
|
placeholder="模板名称"
|
||||||
|
/>
|
||||||
|
<NButton @click="saveTemplate">
|
||||||
|
保存当前投票为模板
|
||||||
|
</NButton>
|
||||||
|
</NSpace>
|
||||||
|
|
||||||
|
<NDivider v-if="savedTemplates.length > 0" />
|
||||||
|
|
||||||
|
<NEmpty
|
||||||
|
v-if="savedTemplates.length === 0"
|
||||||
|
description="暂无保存的模板"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NList v-else>
|
||||||
|
<NListItem
|
||||||
|
v-for="(template, index) in savedTemplates"
|
||||||
|
:key="index"
|
||||||
|
>
|
||||||
|
<NThing :title="template.title">
|
||||||
|
<template #description>
|
||||||
|
<NText>选项数: {{ template.options.length }}</NText>
|
||||||
|
</template>
|
||||||
|
<template #action>
|
||||||
|
<NSpace>
|
||||||
|
<NButton
|
||||||
|
size="small"
|
||||||
|
@click="loadTemplate(template)"
|
||||||
|
>
|
||||||
|
加载
|
||||||
|
</NButton>
|
||||||
|
<NButton
|
||||||
|
size="small"
|
||||||
|
@click="deleteTemplate(index)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</NButton>
|
||||||
|
</NSpace>
|
||||||
|
</template>
|
||||||
|
</NThing>
|
||||||
|
</NListItem>
|
||||||
|
</NList>
|
||||||
|
</NSpace>
|
||||||
|
</NCard>
|
||||||
|
|
||||||
|
<NCard
|
||||||
|
v-if="voteHistoryTab.length > 0 && voteConfig.isEnabled"
|
||||||
|
title="投票历史"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<NList>
|
||||||
|
<NListItem
|
||||||
|
v-for="vote in voteHistoryTab"
|
||||||
|
:key="vote.id"
|
||||||
|
>
|
||||||
|
<NThing :title="vote.title">
|
||||||
|
<template #description>
|
||||||
|
<NSpace
|
||||||
|
vertical
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<NText depth="3">
|
||||||
|
开始于: {{ new Date(vote.startTime * 1000).toLocaleString() }}
|
||||||
|
<span v-if="vote.endTime">
|
||||||
|
- 结束于: {{ new Date(vote.endTime * 1000).toLocaleString() }}
|
||||||
|
</span>
|
||||||
|
</NText>
|
||||||
|
<NText>总票数: {{ vote.totalVotes }}</NText>
|
||||||
|
<NSpace
|
||||||
|
v-for="(option, index) in vote.options"
|
||||||
|
:key="index"
|
||||||
|
>
|
||||||
|
<NTag>{{ option.text }}: {{ option.count }}票 ({{ calculatePercentage(option.count, vote.totalVotes) }}%)</NTag>
|
||||||
|
</NSpace>
|
||||||
|
</NSpace>
|
||||||
|
</template>
|
||||||
|
<template #action>
|
||||||
|
<NButton
|
||||||
|
size="small"
|
||||||
|
type="error"
|
||||||
|
@click="deleteVote(vote.id)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</NButton>
|
||||||
|
</template>
|
||||||
|
</NThing>
|
||||||
|
</NListItem>
|
||||||
|
</NList>
|
||||||
|
</NCard>
|
||||||
|
</NSpace>
|
||||||
|
|
||||||
|
<!-- 设置弹窗 -->
|
||||||
|
<NModal
|
||||||
|
v-model:show="showSettingsModal"
|
||||||
|
preset="card"
|
||||||
|
title="投票设置"
|
||||||
|
style="width: 600px"
|
||||||
|
>
|
||||||
|
<NSpin :show="isLoading">
|
||||||
|
<NSpace vertical>
|
||||||
|
<NSpace vertical>
|
||||||
|
<NText strong>
|
||||||
|
基本设置
|
||||||
|
</NText>
|
||||||
|
|
||||||
|
<NInputGroup>
|
||||||
|
<NInputGroupLabel>触发命令</NInputGroupLabel>
|
||||||
|
<NInput v-model:value="voteConfig.voteCommand" />
|
||||||
|
</NInputGroup>
|
||||||
|
|
||||||
|
<NInputGroup>
|
||||||
|
<NInputGroupLabel>结束命令</NInputGroupLabel>
|
||||||
|
<NInput v-model:value="voteConfig.voteEndCommand" />
|
||||||
|
</NInputGroup>
|
||||||
|
|
||||||
|
<NInputGroup>
|
||||||
|
<NInputGroupLabel>默认标题</NInputGroupLabel>
|
||||||
|
<NInput v-model:value="voteConfig.voteTitle" />
|
||||||
|
</NInputGroup>
|
||||||
|
|
||||||
|
<NInputGroup>
|
||||||
|
<NInputGroupLabel>默认时长</NInputGroupLabel>
|
||||||
|
<NInputNumber
|
||||||
|
v-model:value="voteConfig.voteDurationSeconds"
|
||||||
|
:min="10"
|
||||||
|
>
|
||||||
|
<template #suffix>
|
||||||
|
秒
|
||||||
|
</template>
|
||||||
|
</NInputNumber>
|
||||||
|
</NInputGroup>
|
||||||
|
|
||||||
|
<NCheckbox v-model:checked="voteConfig.showResults">
|
||||||
|
实时显示投票结果
|
||||||
|
</NCheckbox>
|
||||||
|
|
||||||
|
<NCheckbox v-model:checked="voteConfig.allowMultipleVotes">
|
||||||
|
允许重复投票
|
||||||
|
</NCheckbox>
|
||||||
|
|
||||||
|
<NCheckbox v-model:checked="voteConfig.logVotes">
|
||||||
|
记录投票详情
|
||||||
|
</NCheckbox>
|
||||||
|
</NSpace>
|
||||||
|
|
||||||
|
<NDivider />
|
||||||
|
|
||||||
|
<NSpace vertical>
|
||||||
|
<NText strong>
|
||||||
|
礼物投票
|
||||||
|
</NText>
|
||||||
|
|
||||||
|
<NCheckbox v-model:checked="voteConfig.allowGiftVoting">
|
||||||
|
允许通过礼物投票
|
||||||
|
</NCheckbox>
|
||||||
|
|
||||||
|
<NInputGroup v-if="voteConfig.allowGiftVoting">
|
||||||
|
<NInputGroupLabel>最低礼物金额</NInputGroupLabel>
|
||||||
|
<NInputNumber
|
||||||
|
v-model:value="voteConfig.minGiftPrice"
|
||||||
|
:min="0.1"
|
||||||
|
:precision="1"
|
||||||
|
>
|
||||||
|
<template #suffix>
|
||||||
|
元
|
||||||
|
</template>
|
||||||
|
</NInputNumber>
|
||||||
|
</NInputGroup>
|
||||||
|
|
||||||
|
<NRadioGroup v-model:value="voteConfig.voteResultMode">
|
||||||
|
<NSpace>
|
||||||
|
<NRadio :value="0">
|
||||||
|
按人数计票
|
||||||
|
</NRadio>
|
||||||
|
<NRadio :value="1">
|
||||||
|
按礼物价值
|
||||||
|
</NRadio>
|
||||||
|
</NSpace>
|
||||||
|
</NRadioGroup>
|
||||||
|
</NSpace>
|
||||||
|
|
||||||
|
<NDivider />
|
||||||
|
|
||||||
|
<NSpace vertical>
|
||||||
|
<NText strong>
|
||||||
|
显示设置
|
||||||
|
</NText>
|
||||||
|
|
||||||
|
<NSpace>
|
||||||
|
<NInputGroup>
|
||||||
|
<NInputGroupLabel>背景颜色</NInputGroupLabel>
|
||||||
|
<NColorPicker v-model:value="voteConfig.backgroundColor" />
|
||||||
|
</NInputGroup>
|
||||||
|
|
||||||
|
<NInputGroup>
|
||||||
|
<NInputGroupLabel>文本颜色</NInputGroupLabel>
|
||||||
|
<NColorPicker v-model:value="voteConfig.textColor" />
|
||||||
|
</NInputGroup>
|
||||||
|
|
||||||
|
<NInputGroup>
|
||||||
|
<NInputGroupLabel>选项颜色</NInputGroupLabel>
|
||||||
|
<NColorPicker v-model:value="voteConfig.optionColor" />
|
||||||
|
</NInputGroup>
|
||||||
|
</NSpace>
|
||||||
|
|
||||||
|
<NSpace>
|
||||||
|
<NCheckbox v-model:checked="voteConfig.roundedCorners">
|
||||||
|
圆角显示
|
||||||
|
</NCheckbox>
|
||||||
|
|
||||||
|
<NInputGroup>
|
||||||
|
<NInputGroupLabel>显示位置</NInputGroupLabel>
|
||||||
|
<NSelect
|
||||||
|
v-model:value="voteConfig.displayPosition"
|
||||||
|
:options="[
|
||||||
|
{ label: '左侧', value: 'left' },
|
||||||
|
{ label: '右侧', value: 'right' },
|
||||||
|
{ label: '顶部', value: 'top' },
|
||||||
|
{ label: '底部', value: 'bottom' }
|
||||||
|
]"
|
||||||
|
style="width: 120px"
|
||||||
|
/>
|
||||||
|
</NInputGroup>
|
||||||
|
</NSpace>
|
||||||
|
</NSpace>
|
||||||
|
|
||||||
|
<NSpace justify="end">
|
||||||
|
<NButton @click="showSettingsModal = false">
|
||||||
|
取消
|
||||||
|
</NButton>
|
||||||
|
<NButton
|
||||||
|
type="primary"
|
||||||
|
@click="saveVoteConfig"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</NButton>
|
||||||
|
</NSpace>
|
||||||
|
</NSpace>
|
||||||
|
</NSpin>
|
||||||
|
</NModal>
|
||||||
|
</template>
|
||||||
@@ -100,6 +100,13 @@ const props = defineProps<{
|
|||||||
code?: string | undefined
|
code?: string | undefined
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const refinedCode = computed(() => {
|
||||||
|
if (props.code) {
|
||||||
|
return props.code
|
||||||
|
}
|
||||||
|
return accountInfo.value?.biliAuthCode ?? window.$route.query.code?.toString()
|
||||||
|
})
|
||||||
|
|
||||||
async function getUsers() {
|
async function getUsers() {
|
||||||
try {
|
try {
|
||||||
const data = await QueryGetAPI<UpdateLiveLotteryUsersModel>(LOTTERY_API_URL + 'live/get-users', {
|
const data = await QueryGetAPI<UpdateLiveLotteryUsersModel>(LOTTERY_API_URL + 'live/get-users', {
|
||||||
|
|||||||
@@ -1510,6 +1510,11 @@ function getIndexStyle(status: QueueStatus): CSSProperties {
|
|||||||
:disabled="!configCanEdit"
|
:disabled="!configCanEdit"
|
||||||
>
|
>
|
||||||
<NSpin :show="isLoading">
|
<NSpin :show="isLoading">
|
||||||
|
<NAlert
|
||||||
|
type="info"
|
||||||
|
closable
|
||||||
|
title="Tip"
|
||||||
|
/>
|
||||||
<NSpace
|
<NSpace
|
||||||
vertical
|
vertical
|
||||||
:size="20"
|
:size="20"
|
||||||
|
|||||||
@@ -200,8 +200,9 @@ function getTooltip(goods: ResponsePointGoodModel): '开始兑换' | '当前积
|
|||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// 检查实物礼物的地址要求
|
// 检查实物礼物的地址要求 - 仅对没有外部收集链接的实物礼物检查
|
||||||
if (goods.type === GoodsTypes.Physical && !goods.collectUrl &&
|
if (goods.type === GoodsTypes.Physical &&
|
||||||
|
!goods.collectUrl && // 修复:如果有站外链接收集地址,不需要检查用户是否设置了地址
|
||||||
(!biliAuth.value.address || biliAuth.value.address.length === 0)) {
|
(!biliAuth.value.address || biliAuth.value.address.length === 0)) {
|
||||||
return '需要设置地址'
|
return '需要设置地址'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user