diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..e0371d6 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,19 @@ +- @src/api/api-models.ts: 定义了系统中使用的数据模型 +- @src/api/query.ts: 提供了API请求的基础函数 +- @src/api/account.ts: 账户管理相关API + +## 主要目录结构 + +- `src/`: 源代码目录 + - `api/`: API调用和模型定义 + - `assets/`: 静态资源文件 + - `client/`: 客户端相关组件和服务 + - `components/`: Vue组件 + - `composables/`: Vue组合式API函数 + - `data/`: 数据相关模块,包括聊天和弹幕客户端 + - `router/`: 路由配置 + - `store/`: 状态管理 + - `views/`: 页面视图组件 + - `open_live/`: 直播相关视图,包括点歌系统 + - `obs/`: OBS相关视图组件 +- `public/`: 公共静态资源 \ No newline at end of file diff --git a/src/api/api-models.ts b/src/api/api-models.ts index d50c5f2..f9cc960 100644 --- a/src/api/api-models.ts +++ b/src/api/api-models.ts @@ -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; } \ No newline at end of file diff --git a/src/components.d.ts b/src/components.d.ts index a9a8d55..74e9605 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -28,6 +28,7 @@ declare module 'vue' { NGridItem: typeof import('naive-ui')['NGridItem'] NIcon: typeof import('naive-ui')['NIcon'] NImage: typeof import('naive-ui')['NImage'] + NInputGroupLabel: typeof import('naive-ui')['NInputGroupLabel'] NPopconfirm: typeof import('naive-ui')['NPopconfirm'] NScrollbar: typeof import('naive-ui')['NScrollbar'] NSpace: typeof import('naive-ui')['NSpace'] diff --git a/src/data/constants.ts b/src/data/constants.ts index c31db2f..756c16f 100644 --- a/src/data/constants.ts +++ b/src/data/constants.ts @@ -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; diff --git a/src/router/manage.ts b/src/router/manage.ts index b9d01f7..350f9e8 100644 --- a/src/router/manage.ts +++ b/src/router/manage.ts @@ -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', diff --git a/src/router/obs.ts b/src/router/obs.ts index 44104a0..d37f78f 100644 --- a/src/router/obs.ts +++ b/src/router/obs.ts @@ -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, + } } ] } diff --git a/src/store/useWebFetcher.ts b/src/store/useWebFetcher.ts index 1a0d9fe..294af1b 100644 --- a/src/store/useWebFetcher.ts +++ b/src/store/useWebFetcher.ts @@ -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; } } diff --git a/src/views/AboutView.vue b/src/views/AboutView.vue index 593e320..9440b48 100644 --- a/src/views/AboutView.vue +++ b/src/views/AboutView.vue @@ -221,7 +221,7 @@ import { CodeOutline, ServerOutline, HeartOutline, LogoGithub } from '@vicons/io > { 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, + },*/ ], }, ] diff --git a/src/views/OBSLayout.vue b/src/views/OBSLayout.vue index cb5096b..5e7326a 100644 --- a/src/views/OBSLayout.vue +++ b/src/views/OBSLayout.vue @@ -1,18 +1,28 @@ + + + + \ No newline at end of file diff --git a/src/views/obs/QueueOBS.vue b/src/views/obs/QueueOBS.vue index c8a3b7c..60b9a3f 100644 --- a/src/views/obs/QueueOBS.vue +++ b/src/views/obs/QueueOBS.vue @@ -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 { diff --git a/src/views/obs/live-request/ClassicRequestOBS.vue b/src/views/obs/live-request/ClassicRequestOBS.vue index 96dbaab..ef4e11b 100644 --- a/src/views/obs/live-request/ClassicRequestOBS.vue +++ b/src/views/obs/live-request/ClassicRequestOBS.vue @@ -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(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" >
{ ref="footerRef" class="live-request-footer" > - - -
前缀
-
- {{ settings.orderPrefix }} +