Compare commits

..

2 Commits

Author SHA1 Message Date
010309ce16 feat: 更新组件和API模型,优化用户体验
- 在api-models.ts中将goodsId字段更改为goods,以更好地表示商品信息
- 在多个组件中添加NEllipsis组件以优化文本显示
- 在AddressDisplay.vue中调整模板格式,提升可读性
- 在PointOrderCard.vue中更新订单信息的显示逻辑
- 在PointOrderManage.vue中添加批量更新订单状态的功能
2025-05-05 04:10:16 +08:00
f90f2057bb feat: 添加弹幕投票相关功能, 修复礼物兑换外部链接bug
- 在api-models.ts中定义弹幕投票相关类型
- 在constants.ts中添加VOTE_API_URL常量
- 在路由中添加弹幕投票管理和OBS视图
- 更新组件以支持弹幕投票功能
2025-05-05 02:01:01 +08:00
23 changed files with 1989 additions and 313 deletions

28
.cursorrules Normal file
View 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: 图表

View File

@@ -772,7 +772,7 @@ export interface ResponsePointOrder2OwnerModel {
type: GoodsTypes
customer: BiliAuthModel
address?: AddressInfo
goodsId: number
goods: ResponsePointGoodModel
count: number
createAt: number
updateAt: number
@@ -882,4 +882,79 @@ export interface ExtendedUploadFileInfo {
status: 'uploading' | 'finished' | 'error' | 'removed'; // 上传状态
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
View File

@@ -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']

View File

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

View File

@@ -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';
}>();
// 默认封面图片

View File

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

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

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

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

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

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