mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-06 18:36:55 +08:00
Compare commits
2 Commits
aea5e825f6
...
010309ce16
| Author | SHA1 | Date | |
|---|---|---|---|
| 010309ce16 | |||
| f90f2057bb |
28
.cursorrules
Normal file
28
.cursorrules
Normal file
@@ -0,0 +1,28 @@
|
||||
- @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/`: 公共静态资源
|
||||
|
||||
## 项目使用的库
|
||||
|
||||
- @vueuse/core: 提供了一系列的 Vue 3 的实用函数
|
||||
- @vicons/fluent: 图标
|
||||
- naive-ui: 组件库
|
||||
- pinia: 状态管理
|
||||
- vue-router: 路由
|
||||
- vue-echarts: 图表
|
||||
@@ -772,7 +772,7 @@ export interface ResponsePointOrder2OwnerModel {
|
||||
type: GoodsTypes
|
||||
customer: BiliAuthModel
|
||||
address?: AddressInfo
|
||||
goodsId: number
|
||||
goods: ResponsePointGoodModel
|
||||
count: number
|
||||
createAt: number
|
||||
updateAt: number
|
||||
@@ -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;
|
||||
}
|
||||
3
src/components.d.ts
vendored
3
src/components.d.ts
vendored
@@ -22,12 +22,15 @@ declare module 'vue' {
|
||||
NAvatar: typeof import('naive-ui')['NAvatar']
|
||||
NButton: typeof import('naive-ui')['NButton']
|
||||
NCard: typeof import('naive-ui')['NCard']
|
||||
NEllipsis: typeof import('naive-ui')['NEllipsis']
|
||||
NEmpty: typeof import('naive-ui')['NEmpty']
|
||||
NFlex: typeof import('naive-ui')['NFlex']
|
||||
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
|
||||
NGridItem: typeof import('naive-ui')['NGridItem']
|
||||
NIcon: typeof import('naive-ui')['NIcon']
|
||||
NImage: typeof import('naive-ui')['NImage']
|
||||
NInputGroupLabel: typeof import('naive-ui')['NInputGroupLabel']
|
||||
NModal: typeof import('naive-ui')['NModal']
|
||||
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
|
||||
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||
NSpace: typeof import('naive-ui')['NSpace']
|
||||
|
||||
@@ -15,33 +15,72 @@ const { height } = useElementSize(elementRef.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NText v-if="!address" depth="3" italic> 未知 </NText>
|
||||
<NFlex v-else ref="elementRef">
|
||||
<NFlex vertical :size="5">
|
||||
<NText
|
||||
v-if="!address"
|
||||
depth="3"
|
||||
italic
|
||||
>
|
||||
未知
|
||||
</NText>
|
||||
<NFlex
|
||||
v-else
|
||||
ref="elementRef"
|
||||
>
|
||||
<NFlex
|
||||
vertical
|
||||
:size="5"
|
||||
>
|
||||
<NText v-if="size != 'small'">
|
||||
{{ address.province }}
|
||||
<NText depth="3"> 省 </NText>
|
||||
<NText depth="3">
|
||||
省
|
||||
</NText>
|
||||
{{ address.city }}
|
||||
<NText depth="3"> 市 </NText>
|
||||
<NText depth="3">
|
||||
市
|
||||
</NText>
|
||||
{{ address.district }}
|
||||
<NText depth="3"> 区 </NText>
|
||||
<NText depth="3">
|
||||
区
|
||||
</NText>
|
||||
{{ address.street }}
|
||||
</NText>
|
||||
<NText depth="3">
|
||||
<NFlex align="center">
|
||||
<NTag size="tiny" type="info" :bordered="false"> 详细地址 </NTag>
|
||||
{{ address.address }}
|
||||
<NTag
|
||||
size="tiny"
|
||||
type="info"
|
||||
:bordered="false"
|
||||
>
|
||||
详细地址
|
||||
</NTag>
|
||||
<NEllipsis :style="{ maxWidth: size == 'small' ? '120px' : '1000px' }">
|
||||
{{ address.address }}
|
||||
</NEllipsis>
|
||||
</NFlex>
|
||||
</NText>
|
||||
<NText v-if="size != 'small'" depth="3">
|
||||
<NText
|
||||
v-if="size != 'small'"
|
||||
depth="3"
|
||||
>
|
||||
<NFlex align="center">
|
||||
<NTag size="tiny" type="info" :bordered="false"> 收货人 </NTag>
|
||||
<NTag
|
||||
size="tiny"
|
||||
type="info"
|
||||
:bordered="false"
|
||||
>
|
||||
收货人
|
||||
</NTag>
|
||||
<span> {{ address.phone }} {{ address.name }} </span>
|
||||
</NFlex>
|
||||
</NText>
|
||||
</NFlex>
|
||||
<NFlex style="flex: 1" justify="end" align="center">
|
||||
<slot name="actions"></slot>
|
||||
<NFlex
|
||||
style="flex: 1"
|
||||
justify="end"
|
||||
align="center"
|
||||
>
|
||||
<slot name="actions" />
|
||||
</NFlex>
|
||||
</NFlex>
|
||||
</template>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { NCard, NEllipsis, NEmpty, NFlex, NIcon, NImage, NTag, NText } from 'nai
|
||||
const props = defineProps<{
|
||||
goods: ResponsePointGoodModel | undefined;
|
||||
contentStyle?: string | undefined;
|
||||
size?: 'small' | 'default';
|
||||
}>();
|
||||
|
||||
// 默认封面图片
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
import {
|
||||
GoodsTypes,
|
||||
PointOrderStatus,
|
||||
ResponsePointGoodModel,
|
||||
ResponsePointOrder2OwnerModel,
|
||||
ResponsePointOrder2UserModel,
|
||||
ResponsePointOrder2UserModel
|
||||
} from '@/api/api-models'
|
||||
import { QueryPostAPI } from '@/api/query'
|
||||
import { POINT_API_URL } from '@/data/constants'
|
||||
@@ -12,10 +11,13 @@ import { Info24Filled } from '@vicons/fluent'
|
||||
import {
|
||||
DataTableColumns,
|
||||
DataTableRowKey,
|
||||
NAlert,
|
||||
NAutoComplete,
|
||||
NButton,
|
||||
NCard,
|
||||
NDataTable,
|
||||
NDivider,
|
||||
NEllipsis,
|
||||
NEmpty,
|
||||
NFlex,
|
||||
NIcon,
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
NInputGroupLabel,
|
||||
NModal,
|
||||
NScrollbar,
|
||||
NSpace,
|
||||
NStep,
|
||||
NSteps,
|
||||
NTag,
|
||||
@@ -32,9 +35,6 @@ import {
|
||||
NTooltip,
|
||||
useDialog,
|
||||
useMessage,
|
||||
NCard,
|
||||
NSpace,
|
||||
NAlert,
|
||||
} from 'naive-ui'
|
||||
import { computed, h, onMounted, ref, watch } from 'vue'
|
||||
import AddressDisplay from './AddressDisplay.vue'
|
||||
@@ -45,10 +45,10 @@ type OrderType = ResponsePointOrder2UserModel | ResponsePointOrder2OwnerModel
|
||||
const props = defineProps<{
|
||||
order: ResponsePointOrder2UserModel[] | ResponsePointOrder2OwnerModel[]
|
||||
type: 'user' | 'owner'
|
||||
goods?: ResponsePointGoodModel[]
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const emit = defineEmits(['selectedItem'])
|
||||
@@ -71,11 +71,7 @@ const orderAsOwner = computed(() => props.order as ResponsePointOrder2OwnerModel
|
||||
const currentGoods = computed(() => {
|
||||
if (!orderDetail.value) return null
|
||||
|
||||
if (props.type === 'user') {
|
||||
return (orderDetail.value as ResponsePointOrder2UserModel).goods
|
||||
} else {
|
||||
return props.goods?.find((g) => g.id === (orderDetail.value as ResponsePointOrder2OwnerModel).goodsId)
|
||||
}
|
||||
return orderDetail.value.goods
|
||||
})
|
||||
|
||||
const expressOptions = computed(() => {
|
||||
@@ -143,6 +139,7 @@ const orderColumn: DataTableColumns<OrderType> = [
|
||||
},
|
||||
{
|
||||
title: '订单号',
|
||||
minWidth: 70,
|
||||
key: 'id',
|
||||
},
|
||||
{
|
||||
@@ -173,11 +170,9 @@ const orderColumn: DataTableColumns<OrderType> = [
|
||||
{
|
||||
title: '礼物名',
|
||||
key: 'giftName',
|
||||
minWidth: 150,
|
||||
render: (row: OrderType) => {
|
||||
if (row.instanceOf === 'user') {
|
||||
return (row as ResponsePointOrder2UserModel).goods.name
|
||||
}
|
||||
return props.goods?.find((g) => g.id === row.goodsId)?.name || '未知礼物'
|
||||
return row.goods?.name
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -249,9 +244,7 @@ const orderColumn: DataTableColumns<OrderType> = [
|
||||
key: 'address',
|
||||
minWidth: 250,
|
||||
render: (row: OrderType) => {
|
||||
const goodsCollectUrl = row.instanceOf === 'user'
|
||||
? (row as ResponsePointOrder2UserModel).goods.collectUrl
|
||||
: props.goods?.find((g) => g.id === row.goodsId)?.collectUrl
|
||||
const goodsCollectUrl = row.goods.collectUrl
|
||||
|
||||
if (row.type === GoodsTypes.Physical) {
|
||||
return goodsCollectUrl
|
||||
@@ -262,7 +255,7 @@ const orderColumn: DataTableColumns<OrderType> = [
|
||||
text: true,
|
||||
type: 'info'
|
||||
}, () => h(NText, { italic: true }, () => '通过站外链接收集'))
|
||||
: h(AddressDisplay, { address: row.address })
|
||||
: h(AddressDisplay, { address: row.address, size: 'small' })
|
||||
} else {
|
||||
return h(NText, { depth: 3, italic: true }, () => '无需发货')
|
||||
}
|
||||
@@ -277,7 +270,7 @@ const orderColumn: DataTableColumns<OrderType> = [
|
||||
if (row.trackingNumber) {
|
||||
return h(NFlex, { align: 'center', gap: 8 }, () => [
|
||||
h(NTag, { size: 'tiny', bordered: false }, () => row.expressCompany),
|
||||
h(NText, { depth: 3 }, () => row.trackingNumber),
|
||||
h(NEllipsis, { style: { maxWidth: '100px' } }, () => h(NText, { depth: 3 }, () => row.trackingNumber)),
|
||||
])
|
||||
}
|
||||
return h(NText, { depth: 3, italic: true }, () => '尚未发货')
|
||||
@@ -728,7 +721,10 @@ onMounted(() => {
|
||||
size="small"
|
||||
class="address-info-card"
|
||||
>
|
||||
<AddressDisplay :address="orderDetail.address" />
|
||||
<AddressDisplay
|
||||
:address="orderDetail.address"
|
||||
size="default"
|
||||
/>
|
||||
</NCard>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { QueryGetAPI } from '@/api/query'
|
||||
import { BILI_AUTH_API_URL, CURRENT_HOST } from '@/data/constants'
|
||||
import { useBiliAuth } from '@/store/useBiliAuth'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { useStorage, useBreakpoints as useVueUseBreakpoints, breakpointsTailwind } from '@vueuse/core'
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
NStep,
|
||||
NSteps,
|
||||
NText,
|
||||
useMessage
|
||||
useMessage,
|
||||
} from 'naive-ui'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
@@ -30,6 +30,8 @@ type AuthStartModel = {
|
||||
}
|
||||
|
||||
const message = useMessage()
|
||||
const breakpoints = useVueUseBreakpoints(breakpointsTailwind)
|
||||
const isSmallScreen = breakpoints.smaller('sm')
|
||||
|
||||
const guidKey = useStorage('Bili.Auth.Key', uuidv4())
|
||||
const currentToken = useStorage<string>('Bili.Auth.Selected', null)
|
||||
@@ -97,11 +99,15 @@ function checkTimeLeft() {
|
||||
}
|
||||
}
|
||||
function copyCode() {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(startModel.value?.code ?? '')
|
||||
message.success('已复制认证码到剪切板')
|
||||
const textToCopy = currentStep.value === 2
|
||||
? `${CURRENT_HOST}bili-user?auth=${currentToken.value}`
|
||||
: startModel.value?.code ?? ''
|
||||
|
||||
if (navigator.clipboard && textToCopy) {
|
||||
navigator.clipboard.writeText(textToCopy)
|
||||
message.success(currentStep.value === 2 ? '已复制登陆链接到剪切板' : '已复制认证码到剪切板')
|
||||
} else {
|
||||
message.warning('当前环境不支持自动复制, 请手动选择并复制')
|
||||
message.warning('无法复制内容, 请手动选择并复制')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,20 +125,25 @@ onMounted(async () => {
|
||||
<NFlex
|
||||
justify="center"
|
||||
align="center"
|
||||
style="height: 100vh"
|
||||
style="min-height: 100vh; padding: 20px; box-sizing: border-box"
|
||||
>
|
||||
<NCard
|
||||
embedded
|
||||
style="margin: 20px; max-width: 1100px"
|
||||
style="width: 100%; max-width: 1000px"
|
||||
>
|
||||
<template #header>
|
||||
Bilibili 身份验证
|
||||
<NText style="font-size: 1.2em; font-weight: bold">
|
||||
Bilibili 身份验证
|
||||
</NText>
|
||||
</template>
|
||||
<NFlex :wrap="false">
|
||||
<NFlex
|
||||
:wrap="false"
|
||||
:vertical="isSmallScreen"
|
||||
>
|
||||
<NSteps
|
||||
:current="currentStep + 1"
|
||||
vertical
|
||||
style="max-width: 300px"
|
||||
style="min-width: 200px; max-width: 300px; margin-bottom: 20px"
|
||||
>
|
||||
<NStep
|
||||
title="准备认证"
|
||||
@@ -147,149 +158,180 @@ onMounted(async () => {
|
||||
description="现在就已经通过了认证!"
|
||||
/>
|
||||
</NSteps>
|
||||
<template v-if="currentStep == 1">
|
||||
<NSpace
|
||||
vertical
|
||||
justify="center"
|
||||
align="center"
|
||||
style="width: 100%"
|
||||
>
|
||||
<template v-if="!timeOut">
|
||||
<NSpin />
|
||||
<span> 剩余 <NCountdown :duration="timeLeft" /> </span>
|
||||
<NInputGroup>
|
||||
<NInput
|
||||
:value="startModel?.code"
|
||||
:allow-input="() => false"
|
||||
/>
|
||||
<NButton @click="copyCode">
|
||||
复制认证码
|
||||
</NButton>
|
||||
</NInputGroup>
|
||||
<NButton
|
||||
type="primary"
|
||||
tag="a"
|
||||
:href="'https://live.bilibili.com/' + startModel?.targetRoomId"
|
||||
target="_blank"
|
||||
>
|
||||
前往直播间
|
||||
</NButton>
|
||||
</template>
|
||||
<NAlert
|
||||
v-else
|
||||
type="error"
|
||||
<div style="flex-grow: 1; padding-left: 20px; border-left: 1px solid var(--n-border-color); min-width: 0;">
|
||||
<template v-if="currentStep == 1">
|
||||
<NFlex
|
||||
vertical
|
||||
justify="center"
|
||||
align="center"
|
||||
style="width: 100%; height: 100%; padding-top: 20px; min-height: 250px;"
|
||||
>
|
||||
认证超时
|
||||
<NButton
|
||||
type="error"
|
||||
@click="
|
||||
() => {
|
||||
currentStep = 0
|
||||
timeOut = false
|
||||
}
|
||||
"
|
||||
>
|
||||
重新开始
|
||||
</NButton>
|
||||
</NAlert>
|
||||
</NSpace>
|
||||
</template>
|
||||
<template v-else-if="currentStep == 0">
|
||||
<NSpace
|
||||
vertical
|
||||
justify="center"
|
||||
align="center"
|
||||
style="width: 100%"
|
||||
>
|
||||
<NAlert type="info">
|
||||
<NText>
|
||||
点击
|
||||
<template v-if="!timeOut">
|
||||
<NSpin size="large" />
|
||||
<NText style="margin-top: 15px; font-size: 1.1em;">
|
||||
剩余时间:<NCountdown :duration="timeLeft" />
|
||||
</NText>
|
||||
<NText
|
||||
depth="3"
|
||||
style="margin-top: 20px;"
|
||||
>
|
||||
请复制下方的认证码,并前往指定直播间发送:
|
||||
</NText>
|
||||
<NInputGroup style="margin-top: 10px; max-width: 300px;">
|
||||
<NInput
|
||||
:value="startModel?.code"
|
||||
readonly
|
||||
placeholder="认证码"
|
||||
style="text-align: center; font-size: 1.2em; letter-spacing: 2px;"
|
||||
/>
|
||||
<NButton
|
||||
type="primary"
|
||||
@click="copyCode"
|
||||
>
|
||||
复制
|
||||
</NButton>
|
||||
</NInputGroup>
|
||||
<NButton
|
||||
type="info"
|
||||
tag="a"
|
||||
:href="'https://live.bilibili.com/' + startModel?.targetRoomId"
|
||||
target="_blank"
|
||||
style="margin-top: 20px"
|
||||
>
|
||||
前往直播间
|
||||
</NButton>
|
||||
</template>
|
||||
<NAlert
|
||||
v-else
|
||||
type="error"
|
||||
title="认证超时"
|
||||
style="width: 100%; max-width: 400px;"
|
||||
>
|
||||
<NFlex justify="center">
|
||||
<NButton
|
||||
type="error"
|
||||
style="margin-top: 10px"
|
||||
@click="
|
||||
() => {
|
||||
currentStep = 0
|
||||
timeOut = false
|
||||
}
|
||||
"
|
||||
>
|
||||
重新开始认证
|
||||
</NButton>
|
||||
</NFlex>
|
||||
</NAlert>
|
||||
</NFlex>
|
||||
</template>
|
||||
<template v-else-if="currentStep == 0">
|
||||
<NSpace
|
||||
vertical
|
||||
align="stretch"
|
||||
style="width: 100%; padding-top: 10px"
|
||||
>
|
||||
<NAlert type="info">
|
||||
<NText>
|
||||
点击
|
||||
<NText
|
||||
type="primary"
|
||||
strong
|
||||
>
|
||||
开始认证
|
||||
</NText>
|
||||
后请在 5 分钟之内使用
|
||||
<NText
|
||||
strong
|
||||
type="primary"
|
||||
>
|
||||
需要认证的账户
|
||||
</NText>
|
||||
在指定的直播间直播间内发送给出的验证码
|
||||
</NText>
|
||||
</NAlert>
|
||||
<NFlex
|
||||
justify="center"
|
||||
style="margin-top: 20px"
|
||||
>
|
||||
<NButton
|
||||
size="large"
|
||||
type="primary"
|
||||
strong
|
||||
@click="onStartVerify"
|
||||
>
|
||||
开始认证
|
||||
</NText>
|
||||
后请在 5 分钟之内使用
|
||||
<NText
|
||||
strong
|
||||
type="primary"
|
||||
>
|
||||
需要认证的账户
|
||||
</NText>
|
||||
在指定的直播间直播间内发送给出的验证码
|
||||
</NButton>
|
||||
</NFlex>
|
||||
</NSpace>
|
||||
</template>
|
||||
<template v-else-if="currentStep == 2">
|
||||
<NSpace
|
||||
vertical
|
||||
align="stretch"
|
||||
style="width: 100%; padding-top: 10px"
|
||||
>
|
||||
<NAlert
|
||||
type="success"
|
||||
title="验证成功!"
|
||||
style="margin-bottom: 15px"
|
||||
>
|
||||
你已完成验证! 请妥善保存你的登陆链接, 请勿让其他人获取. 丢失后可以再次通过认证流程获得.
|
||||
<br>
|
||||
要在其他地方登陆, 或者需要重新登陆的话把这个链接复制到浏览器地址栏打开即可
|
||||
</NAlert>
|
||||
<NText strong>
|
||||
你的登陆链接为:
|
||||
</NText>
|
||||
</NAlert>
|
||||
<NText
|
||||
depth="3"
|
||||
style="font-size: 15px"
|
||||
>
|
||||
准备好了吗?
|
||||
</NText>
|
||||
<NButton
|
||||
size="large"
|
||||
type="primary"
|
||||
@click="onStartVerify"
|
||||
>
|
||||
开始认证
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
<template v-else-if="currentStep == 2">
|
||||
<NFlex
|
||||
justify="center"
|
||||
align="center"
|
||||
vertical
|
||||
style="width: 100%"
|
||||
>
|
||||
<NAlert type="success">
|
||||
你已完成验证! 请妥善保存你的登陆链接, 请勿让其他人获取. 丢失后可以再次通过认证流程获得.
|
||||
<br>
|
||||
要在其他地方登陆, 或者需要重新登陆的话把这个链接复制到浏览器地址栏打开即可
|
||||
</NAlert>
|
||||
<NText> 你的登陆链接为: </NText>
|
||||
<NInputGroup>
|
||||
<NInput
|
||||
:value="`${CURRENT_HOST}bili-user?auth=${currentToken}`"
|
||||
type="textarea"
|
||||
:allow-input="() => false"
|
||||
readonly
|
||||
style="margin-top: 5px"
|
||||
/>
|
||||
<NButton
|
||||
type="info"
|
||||
style="height: 100%"
|
||||
@click="copyCode"
|
||||
<NFlex
|
||||
justify="end"
|
||||
style="margin-top: 10px"
|
||||
>
|
||||
复制登陆链接
|
||||
</NButton>
|
||||
</NInputGroup>
|
||||
<NFlex>
|
||||
<NButton
|
||||
type="primary"
|
||||
@click="$router.push({ name: 'bili-user' })"
|
||||
<NButton
|
||||
type="primary"
|
||||
@click="copyCode"
|
||||
>
|
||||
复制登陆链接
|
||||
</NButton>
|
||||
</NFlex>
|
||||
<NFlex
|
||||
justify="center"
|
||||
style="margin-top: 20px"
|
||||
:wrap="true"
|
||||
>
|
||||
前往个人中心
|
||||
</NButton>
|
||||
<NPopconfirm
|
||||
positive-text="继续"
|
||||
@positive-click="
|
||||
() => {
|
||||
currentStep = 0
|
||||
//@ts-ignore
|
||||
currentToken = null
|
||||
guidKey = uuidv4()
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton type="warning">
|
||||
认证其他账号
|
||||
</NButton>
|
||||
</template>
|
||||
这将会登出当前已认证的账号, 请先在认证其他账号前保存你的登陆链接
|
||||
</NPopconfirm>
|
||||
</NFlex>
|
||||
</NFlex>
|
||||
</template>
|
||||
<NButton
|
||||
type="primary"
|
||||
@click="$router.push({ name: 'bili-user' })"
|
||||
>
|
||||
前往个人中心
|
||||
</NButton>
|
||||
<NPopconfirm
|
||||
positive-text="继续"
|
||||
negative-text="取消"
|
||||
@positive-click="
|
||||
() => {
|
||||
currentStep = 0
|
||||
//@ts-ignore
|
||||
currentToken = null
|
||||
guidKey = uuidv4()
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton type="warning">
|
||||
认证其他账号
|
||||
</NButton>
|
||||
</template>
|
||||
这将会登出当前已认证的账号, 请先在认证其他账号前保存你的登陆链接
|
||||
</NPopconfirm>
|
||||
</NFlex>
|
||||
</NSpace>
|
||||
</template>
|
||||
</div>
|
||||
</NFlex>
|
||||
</NCard>
|
||||
</NFlex>
|
||||
|
||||
@@ -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,
|
||||
},*/
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 主链接区域显示的链接
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useAccount } from '@/api/account'
|
||||
import { GoodsTypes, PointOrderStatus, ResponsePointGoodModel, ResponsePointOrder2OwnerModel } from '@/api/api-models'
|
||||
import { GoodsTypes, PointOrderStatus, ResponsePointGoodModel, ResponsePointOrder2OwnerModel, ResponsePointUserModel } from '@/api/api-models'
|
||||
import { QueryGetAPI, QueryPostAPI } from '@/api/query'
|
||||
import PointOrderCard from '@/components/manage/PointOrderCard.vue'
|
||||
import { POINT_API_URL } from '@/data/constants'
|
||||
@@ -17,12 +17,16 @@ import {
|
||||
NDivider,
|
||||
NEmpty,
|
||||
NFlex,
|
||||
NModal,
|
||||
NPopconfirm,
|
||||
NSelect,
|
||||
NSpace,
|
||||
NSpin,
|
||||
NText,
|
||||
useMessage,
|
||||
} from 'naive-ui'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import PointUserDetailCard from './PointUserDetailCard.vue'
|
||||
|
||||
// 订单筛选设置类型定义
|
||||
type OrderFilterSettings = {
|
||||
@@ -62,6 +66,8 @@ const filteredOrders = computed(() => {
|
||||
|
||||
const isLoading = ref(false)
|
||||
const selectedItem = ref<DataTableRowKey[]>()
|
||||
const targetStatus = ref<PointOrderStatus>()
|
||||
const showStatusModal = ref(false)
|
||||
|
||||
// 获取所有订单
|
||||
async function getOrders() {
|
||||
@@ -104,6 +110,54 @@ async function deleteOrder() {
|
||||
}
|
||||
}
|
||||
|
||||
// 打开状态更新模态框
|
||||
function openStatusUpdateModal() {
|
||||
if (!selectedItem.value?.length) {
|
||||
message.warning('请选择要更新的订单')
|
||||
return
|
||||
}
|
||||
showStatusModal.value = true
|
||||
}
|
||||
|
||||
// 批量更新订单状态
|
||||
async function batchUpdateOrderStatus() {
|
||||
if (!selectedItem.value?.length) {
|
||||
message.warning('请选择要更新的订单')
|
||||
return
|
||||
}
|
||||
|
||||
if (targetStatus.value === undefined) {
|
||||
message.warning('请选择目标状态')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const requestData = {
|
||||
orderIds: selectedItem.value,
|
||||
status: targetStatus.value
|
||||
}
|
||||
|
||||
const data = await QueryPostAPI<number[]>(POINT_API_URL + 'batch-update-order-status', requestData)
|
||||
if (data.code == 200) {
|
||||
message.success('更新成功')
|
||||
// 更新本地订单状态
|
||||
orders.value.forEach(order => {
|
||||
if (data.data.includes(order.id)) {
|
||||
order.status = targetStatus.value as PointOrderStatus
|
||||
order.updateAt = Date.now()
|
||||
}
|
||||
})
|
||||
targetStatus.value = undefined
|
||||
showStatusModal.value = false
|
||||
} else {
|
||||
message.error('更新失败: ' + data.message)
|
||||
}
|
||||
} catch (err) {
|
||||
message.error('更新失败: ' + err)
|
||||
console.log(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 订单状态文本映射
|
||||
const statusText = {
|
||||
[PointOrderStatus.Completed]: '已完成',
|
||||
@@ -116,7 +170,7 @@ function exportData() {
|
||||
try {
|
||||
const text = objectsToCSV(
|
||||
filteredOrders.value.map((s) => {
|
||||
const gift = props.goods.find((g) => g.id == s.goodsId)
|
||||
const gift = s.goods
|
||||
return {
|
||||
订单号: s.id,
|
||||
订单类型: s.type == GoodsTypes.Physical ? '实体' : '虚拟',
|
||||
@@ -237,46 +291,97 @@ onMounted(async () => {
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="filterSettings.customer"
|
||||
:options="
|
||||
new List(orders)
|
||||
.DistinctBy((s) => s.customer.userId)
|
||||
.Select((s) => ({ label: s.customer.name, value: s.customer.userId }))
|
||||
.ToArray()
|
||||
:options="new List(orders)
|
||||
.DistinctBy((s) => s.customer.userId)
|
||||
.Select((s) => ({ label: s.customer.name, value: s.customer.userId }))
|
||||
.ToArray()
|
||||
"
|
||||
placeholder="用户"
|
||||
clearable
|
||||
style="min-width: 120px; max-width: 150px"
|
||||
/>
|
||||
<NCheckbox
|
||||
v-model:checked="filterSettings.onlyRequireShippingInfo"
|
||||
>
|
||||
<NCheckbox v-model:checked="filterSettings.onlyRequireShippingInfo">
|
||||
仅包含未填写快递单号的订单
|
||||
</NCheckbox>
|
||||
</NFlex>
|
||||
</NCard>
|
||||
|
||||
<NDivider title-placement="left">
|
||||
<NPopconfirm @positive-click="deleteOrder">
|
||||
<template #trigger>
|
||||
<NButton
|
||||
size="tiny"
|
||||
type="error"
|
||||
:disabled="!selectedItem?.length"
|
||||
>
|
||||
删除选中的订单 | {{ selectedItem?.length ?? 0 }}
|
||||
</NButton>
|
||||
</template>
|
||||
确定删除吗?
|
||||
</NPopconfirm>
|
||||
<NFlex
|
||||
:gap="8"
|
||||
:wrap="false"
|
||||
>
|
||||
<NPopconfirm @positive-click="deleteOrder">
|
||||
<template #trigger>
|
||||
<NButton
|
||||
size="tiny"
|
||||
type="error"
|
||||
:disabled="!selectedItem?.length"
|
||||
>
|
||||
删除选中的订单 | {{ selectedItem?.length ?? 0 }}
|
||||
</NButton>
|
||||
</template>
|
||||
确定删除吗?
|
||||
</NPopconfirm>
|
||||
|
||||
<NPopconfirm @positive-click="openStatusUpdateModal">
|
||||
<template #trigger>
|
||||
<NButton
|
||||
size="tiny"
|
||||
type="info"
|
||||
:disabled="!selectedItem?.length"
|
||||
>
|
||||
批量更新状态
|
||||
</NButton>
|
||||
</template>
|
||||
确定要更新选中订单的状态吗?
|
||||
</NPopconfirm>
|
||||
</NFlex>
|
||||
</NDivider>
|
||||
|
||||
<!-- 订单列表 -->
|
||||
<PointOrderCard
|
||||
:order="filteredOrders"
|
||||
:goods="goods"
|
||||
type="owner"
|
||||
@selected-item="(items) => (selectedItem = items)"
|
||||
/>
|
||||
|
||||
<!-- 状态选择模态框 -->
|
||||
<NModal
|
||||
v-model:show="showStatusModal"
|
||||
title="选择目标状态"
|
||||
preset="card"
|
||||
style="max-width: 400px"
|
||||
>
|
||||
<NSpace vertical>
|
||||
<NText>请选择您想要将订单更新为的状态</NText>
|
||||
<NSelect
|
||||
v-model:value="targetStatus"
|
||||
:options="[
|
||||
{ label: '已完成', value: PointOrderStatus.Completed },
|
||||
{ label: '等待发货', value: PointOrderStatus.Pending },
|
||||
{ label: '已发货', value: PointOrderStatus.Shipped },
|
||||
]"
|
||||
placeholder="选择状态"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<NFlex
|
||||
justify="end"
|
||||
:gap="12"
|
||||
>
|
||||
<NButton @click="showStatusModal = false">
|
||||
取消
|
||||
</NButton>
|
||||
<NButton
|
||||
type="primary"
|
||||
:disabled="targetStatus === undefined"
|
||||
@click="batchUpdateOrderStatus"
|
||||
>
|
||||
确认更新
|
||||
</NButton>
|
||||
</NFlex>
|
||||
</NSpace>
|
||||
</NModal>
|
||||
</template>
|
||||
</NSpin>
|
||||
</template>
|
||||
|
||||
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);
|
||||
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 {
|
||||
|
||||
@@ -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'] {
|
||||
|
||||
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
|
||||
}>()
|
||||
|
||||
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', {
|
||||
|
||||
@@ -1510,6 +1510,11 @@ function getIndexStyle(status: QueueStatus): CSSProperties {
|
||||
:disabled="!configCanEdit"
|
||||
>
|
||||
<NSpin :show="isLoading">
|
||||
<NAlert
|
||||
type="info"
|
||||
closable
|
||||
title="Tip"
|
||||
/>
|
||||
<NSpace
|
||||
vertical
|
||||
: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)) {
|
||||
return '需要设置地址'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user