feat: 添加弹幕投票相关功能, 修复礼物兑换外部链接bug

- 在api-models.ts中定义弹幕投票相关类型
- 在constants.ts中添加VOTE_API_URL常量
- 在路由中添加弹幕投票管理和OBS视图
- 更新组件以支持弹幕投票功能
This commit is contained in:
2025-05-05 02:01:01 +08:00
parent aea5e825f6
commit f90f2057bb
18 changed files with 1596 additions and 114 deletions

19
.cursorrules Normal file
View 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/`: 公共静态资源

View File

@@ -883,3 +883,78 @@ export interface ExtendedUploadFileInfo {
thumbnailUrl?: string; // 缩略图URL
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
View File

@@ -28,6 +28,7 @@ declare module 'vue' {
NGridItem: typeof import('naive-ui')['NGridItem']
NIcon: typeof import('naive-ui')['NIcon']
NImage: typeof import('naive-ui')['NImage']
NInputGroupLabel: typeof import('naive-ui')['NInputGroupLabel']
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSpace: typeof import('naive-ui')['NSpace']

View File

@@ -65,6 +65,8 @@ export const ANALYZE_API_URL = BASE_API_URL + 'analyze/';
export const CHECKIN_API_URL = BASE_API_URL + 'checkin/';
export const USER_CONFIG_API_URL = BASE_API_URL + 'user-config/';
export const FILE_API_URL = BASE_API_URL + 'files/';
export const VOTE_API_URL = BASE_API_URL + 'vote/';
export type TemplateMapType = {
[key: string]: {
name: string;

View File

@@ -144,6 +144,16 @@ export default //管理页面
isNew: true
}
},
{
path: 'vote',
name: 'manage-danmakuVote',
component: () => import('@/views/open_live/DanmakuVote.vue'),
meta: {
title: '弹幕投票',
keepAlive: true,
danmaku: true
}
},
{
path: 'live',
name: 'manage-live',

View File

@@ -74,6 +74,15 @@ export default {
title: '弹幕姬',
forceReload: true,
}
},
{
path: 'danmaku-vote',
name: 'obs-danmaku-vote',
component: () => import('@/views/obs/DanmakuVoteOBS.vue'),
meta: {
title: '弹幕投票',
forceReload: true,
}
}
]
}

View File

@@ -1,22 +1,20 @@
import { cookie, useAccount } from '@/api/account';
import { getEventType, recordEvent, streamingInfo } from '@/client/data/info';
import { QueryBiliAPI } from '@/client/data/utils';
import { BASE_HUB_URL, isDev, isTauri } from '@/data/constants';
import BaseDanmakuClient from '@/data/DanmakuClients/BaseDanmakuClient';
import DirectClient, { DirectClientAuthInfo } from '@/data/DanmakuClients/DirectClient';
import OpenLiveClient from '@/data/DanmakuClients/OpenLiveClient';
import { DirectClientAuthInfo } from '@/data/DanmakuClients/DirectClient';
import * as signalR from '@microsoft/signalr';
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 { computed, ref, shallowRef } from 'vue'; // shallowRef 用于非深度响应对象
import { useRoute } from 'vue-router';
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 { getVersion } from '@tauri-apps/api/app';
import { onReceivedNotification } from '@/client/data/notification';
import { useDanmakuClient } from './useDanmakuClient';
export const useWebFetcher = defineStore('WebFetcher', () => {
@@ -335,6 +333,12 @@ export const useWebFetcher = defineStore('WebFetcher', () => {
Success: true,
Data: data
} as ResponseFetchRequestData;
} else {
return {
Message: '请求失败: ' + result.statusText,
Success: false,
Data: ''
} as ResponseFetchRequestData;
}
}

View File

@@ -221,7 +221,7 @@ import { CodeOutline, ServerOutline, HeartOutline, LogoGithub } from '@vicons/io
>
<NButton
tag="a"
href="hhttps://microsoft.github.io/garnet/"
href="https://microsoft.github.io/garnet/"
target="_blank"
text
style="padding: 0; color: inherit;"

View File

@@ -329,6 +329,16 @@ const menuOptions = computed(() => {
icon: renderIcon(TabletSpeaker24Filled),
disabled: !isBiliVerified.value,
},
/*{
label: () => !isBiliVerified.value ? '弹幕投票' : h(
RouterLink,
{ to: { name: 'manage-danmakuVote' } },
{ default: () => '弹幕投票' },
),
key: 'manage-danmakuVote',
icon: renderIcon(Chat24Filled),
disabled: !isBiliVerified.value,
},*/
],
},
]

View File

@@ -1,18 +1,28 @@
<script setup lang="ts">
import { useAccount } from '@/api/account'
import { useWebFetcher } from '@/store/useWebFetcher'
import { NSpin } from 'naive-ui'
import { onMounted, onUnmounted, ref } from 'vue'
const timer = ref<any>()
const visible = 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('')
onMounted(() => {
onMounted(async () => {
timer.value = setInterval(() => {
if (!visible.value || !active.value) return
window.$mitt.emit('onOBSComponentUpdate')
}, 1000)
if (accountInfo.value.id) {
await webfetcher.Start()
}
//@ts-expect-error 这里获取不了
if (window.obsstudio) {
//@ts-expect-error 这里获取不了
@@ -51,6 +61,7 @@ onUnmounted(() => {
:is="Component"
:active
:visible
:code="code"
/>
<template #fallback>
<NSpin show />

View File

@@ -2,15 +2,15 @@
import { copyToClipboard, downloadImage } from '@/Utils'
import { DisableFunction, EnableFunction, SaveAccountSettings, SaveSetting, useAccount } from '@/api/account'
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 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 { Delete24Filled, Delete24Regular, Eye24Filled, EyeOff24Filled, Info24Filled } from '@vicons/fluent'
import { Heart, HeartOutline, TrashBin } from '@vicons/ionicons5'
import { useStorage } from '@vueuse/core'
import QuestionDisplayCard from './QuestionDisplayCard.vue'
// @ts-ignore
import { saveAs } from 'file-saver'
import html2canvas from 'html2canvas'
@@ -89,7 +89,7 @@ const setting = computed({
// 分享链接 (统一 Host, 根据选择的标签附加参数)
const shareUrlWithTag = (tag: string | null) => {
const base = `${CURRENT_HOST}@${accountInfo.value?.name}/question-box`
return tag ? `${base}?tag=${encodeURIComponent(tag)}` : base
return tag ? `${base}?tag=${tag}` : base
}
// 主链接区域显示的链接

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

View File

@@ -526,6 +526,7 @@ onUnmounted(() => {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 6px;
min-height: 36px;
flex-wrap: wrap;
}
.queue-list-item-user-name {
@@ -534,8 +535,17 @@ onUnmounted(() => {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 50%;
max-width: 60%;
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 {
@@ -705,6 +715,9 @@ onUnmounted(() => {
font-weight: 600;
color: white;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@keyframes animated-border {

View File

@@ -6,7 +6,6 @@ import {
import { useElementSize } from '@vueuse/core'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { Vue3Marquee } from 'vue3-marquee'
import { NDivider, NEmpty } from 'naive-ui'
import { useLiveRequestData } from './useLiveRequestData'
@@ -14,6 +13,7 @@ const props = defineProps<{
id?: number,
active?: boolean,
visible?: boolean,
speedMultiplier?: number,
}>()
const route = useRoute()
@@ -21,6 +21,15 @@ const currentId = computed(() => {
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 {
songs,
settings,
@@ -37,10 +46,37 @@ const listContainerRef = ref()
const { height, width } = useElementSize(listContainerRef)
const itemHeight = 40
const isMoreThanContainer = computed(() => {
return activeSongs.value.length * itemHeight > height.value
const listInnerRef = ref<HTMLElement | null>(null)
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(() => {
update()
initRTC()
@@ -102,13 +138,11 @@ onUnmounted(() => {
class="live-request-content"
>
<template v-if="activeSongs.length > 0">
<Vue3Marquee
:key="key"
<div
ref="listInnerRef"
class="live-request-list"
vertical
:duration="20"
:pause="!isMoreThanContainer"
:style="`height: ${height}px;width: ${width}px;`"
:class="{ animating: isMoreThanContainer }"
:style="`width: ${width}px;`"
>
<div
v-for="(song, index) in activeSongs"
@@ -116,7 +150,6 @@ onUnmounted(() => {
class="live-request-list-item"
:from="song.from as number"
:status="song.status as number"
:style="`height: ${itemHeight}px`"
>
<div
class="live-request-list-item-index"
@@ -125,28 +158,23 @@ onUnmounted(() => {
{{ index + 1 }}
</div>
<div class="live-request-list-item-song-name">
{{ song.songName }}
{{ song.songName || '未知歌曲' }}
</div>
<p
<div
v-if="settings.showUserName"
class="live-request-list-item-name"
>
{{ song.from == SongRequestFrom.Manual ? '主播添加' : song.user?.name }}
</p>
{{ song.from == SongRequestFrom.Manual ? '主播添加' : song.user?.name || '未知用户' }}
</div>
<div
v-if="settings.showFanMadelInfo"
v-if="settings.showFanMadelInfo && (song.user?.fans_medal_level ?? 0) > 0"
class="live-request-list-item-level"
: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>
<NDivider
v-if="isMoreThanContainer"
class="live-request-footer-divider"
style="margin: 10px 0 10px 0"
/>
</Vue3Marquee>
</div>
</template>
<div
v-else
@@ -163,56 +191,82 @@ onUnmounted(() => {
ref="footerRef"
class="live-request-footer"
>
<Vue3Marquee
:key="key"
ref="footerListRef"
class="live-request-footer-marquee"
:duration="10"
animate-on-overflow-only
>
<span
class="live-request-tag"
type="prefix"
>
<div class="live-request-tag-key">前缀</div>
<div class="live-request-tag-value">
{{ settings.orderPrefix }}
<div class="live-request-footer-info">
<div class="live-request-footer-tags">
<div
class="live-request-footer-tag"
type="prefix"
>
<span class="tag-label">前缀</span>
<span class="tag-value">{{ settings.orderPrefix }}</span>
</div>
</span>
<span
class="live-request-tag"
type="prefix"
>
<div class="live-request-tag-key">允许</div>
<div class="live-request-tag-value">
{{ settings.allowAllDanmaku ? '所有弹幕' : allowGuardTypes.length > 0 ? allowGuardTypes.join(',') : '无' }}
<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>
</span>
<span
class="live-request-tag"
type="sc"
>
<div class="live-request-tag-key">SC点歌</div>
<div class="live-request-tag-value">
{{ settings.allowSC ? '> ¥' + settings.scMinPrice : '不允许' }}
<div
class="live-request-footer-tag"
type="sc"
>
<span class="tag-label">SC点歌</span>
<span class="tag-value">{{ settings.allowSC ? '> ¥' + settings.scMinPrice : '不允许' }}</span>
</div>
</span>
<span
class="live-request-tag"
type="fan-madel"
>
<div class="live-request-tag-key">粉丝牌</div>
<div class="live-request-tag-value">
{{
settings.needWearFanMedal
? settings.fanMedalMinLevel > 0
? '> ' + settings.fanMedalMinLevel
: '佩戴'
: '无需'
}}
<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>
</span>
</Vue3Marquee>
<div
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>
</template>
@@ -327,33 +381,72 @@ onUnmounted(() => {
.live-request-content {
background-color: #0f0f0f4f;
margin: 10px;
padding: 10px;
padding: 8px;
height: 100%;
border-radius: 10px;
overflow-x: hidden;
overflow: hidden;
}
.marquee {
justify-items: left;
.live-request-list {
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 {
display: flex;
width: 100%;
align-self: flex-start;
position: relative;
align-items: center;
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 {
font-size: 18px;
font-size: 16px;
font-weight: bold;
overflow: hidden;
text-overflow: ellipsis;
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;
white-space: nowrap;
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 {
@@ -394,10 +492,10 @@ onUnmounted(() => {
.live-request-list-item-level {
text-align: center;
height: 18px;
padding: 2px;
padding: 2px 6px;
min-width: 15px;
border-radius: 5px;
background-color: #0f0f0f48;
background-color: rgba(0, 0, 0, 0.3);
color: rgba(204, 204, 204, 0.993);
font-size: 12px;
}
@@ -408,34 +506,95 @@ onUnmounted(() => {
.live-request-footer {
margin: 0 5px 5px 5px;
height: 60px;
border-radius: 5px;
background-color: #0f0f0f4f;
background-color: rgba(0, 0, 0, 0.25);
border-radius: 8px;
padding: 8px 6px;
overflow: hidden;
height: auto;
min-height: 40px;
max-height: 60px;
display: flex;
align-items: center;
}
.live-request-tag {
display: flex;
margin: 5px 0 5px 5px;
height: 40px;
border-radius: 3px;
background-color: #0f0f0f4f;
padding: 4px;
padding-right: 6px;
display: flex;
.live-request-footer-info {
width: 100%;
overflow: hidden;
position: relative;
}
.live-request-footer-tags {
display: inline-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;
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 {
font-style: italic;
color: rgb(211, 211, 211);
.live-request-footer-tag[type="prefix"] {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.12), rgba(37, 99, 235, 0.18));
}
.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;
}
.live-request-tag-value {
font-size: 14px;
font-weight: 600;
color: white;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.live-request-list-item-index[index='1'] {

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

View File

@@ -100,6 +100,13 @@ const props = defineProps<{
code?: string | undefined
}>()
const refinedCode = computed(() => {
if (props.code) {
return props.code
}
return accountInfo.value?.biliAuthCode ?? window.$route.query.code?.toString()
})
async function getUsers() {
try {
const data = await QueryGetAPI<UpdateLiveLotteryUsersModel>(LOTTERY_API_URL + 'live/get-users', {

View File

@@ -1510,6 +1510,11 @@ function getIndexStyle(status: QueueStatus): CSSProperties {
:disabled="!configCanEdit"
>
<NSpin :show="isLoading">
<NAlert
type="info"
closable
title="Tip"
/>
<NSpace
vertical
:size="20"

View File

@@ -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)) {
return '需要设置地址'
}