diff --git a/src/api/api-models.ts b/src/api/api-models.ts index 2049c2a..cb0bb2f 100644 --- a/src/api/api-models.ts +++ b/src/api/api-models.ts @@ -1018,4 +1018,5 @@ export interface VoteOBSData { optionColor: string roundedCorners: boolean displayPosition: string + endTime?: number } diff --git a/src/client/ClientLayout.vue b/src/client/ClientLayout.vue index 52e815b..4141618 100644 --- a/src/client/ClientLayout.vue +++ b/src/client/ClientLayout.vue @@ -15,7 +15,7 @@ import { RouterLink, RouterView } from 'vue-router' // 引入 Vue Router 组件 import { ACCOUNT, GetSelfAccount, isLoadingAccount, isLoggedIn } from '@/api/account' import { useWebFetcher } from '@/store/useWebFetcher' -import { initAll, OnClientUnmounted } from './data/initialize' +import { initAll, OnClientUnmounted, clientInited, clientInitStage } from './data/initialize' import { useDanmakuWindow } from './store/useDanmakuWindow' // 引入子组件 import WindowBar from './WindowBar.vue' @@ -141,6 +141,18 @@ onMounted(() => { + + + + + {{ clientInitStage || '初始化中...' }} + + + + { > - - + + @@ -287,8 +299,8 @@ onMounted(() => { - - + + @@ -455,4 +467,34 @@ onMounted(() => { opacity: 0; transform: translateX(20px); } + + .init-overlay { + position: fixed; + top: 30px; + left: 0; + right: 0; + bottom: 0; + background: var(--n-color); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; + } + .init-overlay-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + } + .init-stage { + color: #999; + } + .fade-enter-active, + .fade-leave-active { + transition: opacity 0.2s ease; + } + .fade-enter-from, + .fade-leave-to { + opacity: 0; + } diff --git a/src/client/data/initialize.ts b/src/client/data/initialize.ts index 7c38b93..c054e24 100644 --- a/src/client/data/initialize.ts +++ b/src/client/data/initialize.ts @@ -14,7 +14,7 @@ import { import { openUrl } from '@tauri-apps/plugin-opener' import { relaunch } from '@tauri-apps/plugin-process' import { check } from '@tauri-apps/plugin-updater' -import { h } from 'vue' +import { h, ref } from 'vue' import { isLoggedIn, useAccount } from '@/api/account' import { CN_HOST, isDev } from '@/data/constants' import { useWebFetcher } from '@/store/useWebFetcher' @@ -29,6 +29,7 @@ import { getBuvid, getRoomKey } from './utils' const accountInfo = useAccount() export const clientInited = ref(false) +export const clientInitStage = ref('') let tray: TrayIcon let heartbeatTimer: number | null = null let updateCheckTimer: number | null = null @@ -154,9 +155,23 @@ async function handleUpdateInstall(update: any) { // 显示下载进度通知 let downloaded = 0 let contentLength = 0 + const progressPercentage = ref(0) + const progressText = ref('准备下载更新...') const progressNotification = window.$notification.info({ title: '正在下载更新', - content: '更新下载中,请稍候...', + content: () => + h('div', { style: 'display: flex; flex-direction: column; gap: 10px; min-width: 240px;' }, [ + h('div', { + style: 'height: 6px; border-radius: 999px; background: rgba(0,0,0,0.12); overflow: hidden; backdrop-filter: blur(6px);', + }, [ + h('div', { + style: `height: 100%; width: ${progressPercentage.value}%; background: linear-gradient(90deg, #5c7cfa, #91a7ff); transition: width 0.2s ease;`, + }), + ]), + h('div', { + style: 'font-size: 12px; color: var(--n-text-color); text-align: center;', + }, progressText.value), + ]), closable: false, duration: 0, }) @@ -167,17 +182,27 @@ async function handleUpdateInstall(update: any) { case 'Started': contentLength = event.data.contentLength || 0 info(`[更新] 开始下载 ${contentLength} 字节`) + progressPercentage.value = 0 + progressText.value = '正在连接下载源...' break case 'Progress': { downloaded += event.data.chunkLength const progress = contentLength > 0 ? Math.round((downloaded / contentLength) * 100) : 0 - progressNotification.content = `下载进度: ${progress}% (${Math.round(downloaded / 1024 / 1024)}MB / ${Math.round(contentLength / 1024 / 1024)}MB)` + progressPercentage.value = Number.isFinite(progress) ? progress : 0 + const downloadedMb = Math.max(downloaded / 1024 / 1024, 0) + const totalMb = Math.max(contentLength / 1024 / 1024, 0) + const formatMb = (value: number) => + value >= 100 ? Math.round(value).toString() : (Math.round(value * 10) / 10).toString() + progressText.value = contentLength > 0 + ? `已下载 ${formatMb(downloadedMb)}MB / ${formatMb(totalMb)}MB` + : '正在下载更新...' info(`[更新] 已下载 ${downloaded} / ${contentLength} 字节`) break } case 'Finished': info('[更新] 下载完成') - progressNotification.content = '下载完成,正在安装...' + progressPercentage.value = 100 + progressText.value = '下载完成,正在安装...' break } }) @@ -217,6 +242,7 @@ export async function initAll(isOnBoot: boolean) { if (clientInited.value) { return } + clientInitStage.value = '初始化中...' // 初始检查更新(不阻塞初始化) if (!isDev) { void checkUpdate() @@ -246,10 +272,13 @@ export async function initAll(isOnBoot: boolean) { await attachConsole() const settings = useSettings() const biliCookie = useBiliCookie() + clientInitStage.value = '加载设置...' await settings.init() info('[init] 已加载账户信息') + clientInitStage.value = '加载 Bilibili Cookie...' biliCookie.init() info('[init] 已加载bilibili cookie') + clientInitStage.value = '初始化基础信息...' initInfo() info('[init] 开始更新数据') @@ -258,6 +287,7 @@ export async function initAll(isOnBoot: boolean) { title: '正在初始化弹幕客户端...', closable: false, }) + clientInitStage.value = '初始化弹幕客户端...' const result = await initDanmakuClient() danmakuInitNoticeRef.destroy() if (result.success) { @@ -265,16 +295,26 @@ export async function initAll(isOnBoot: boolean) { title: '弹幕客户端初始化完成', duration: 3000, }) + clientInitStage.value = '弹幕客户端初始化完成' } else { window.$notification.error({ title: `弹幕客户端初始化失败: ${result.message}`, }) + clientInitStage.value = '弹幕客户端初始化失败' } } info('[init] 已加载弹幕客户端') // 初始化系统托盘图标和菜单 + clientInitStage.value = '创建系统托盘...' const menu = await Menu.new({ items: [ + { + id: 'open-devtools', + text: '打开调试控制台', + action: () => { + void invoke('open_dev_tools') + }, + }, { id: 'quit', text: '退出', @@ -299,6 +339,7 @@ export async function initAll(isOnBoot: boolean) { }, } tray = await TrayIcon.new(options) + clientInitStage.value = '系统托盘就绪' appWindow.setMinSize(new PhysicalSize(720, 480)) @@ -319,15 +360,14 @@ export async function initAll(isOnBoot: boolean) { } }) - // 监听f12事件 - if (!isDev) { - window.addEventListener('keydown', (event) => { - if (event.key === 'F12') { - event.preventDefault() - event.stopPropagation() - } - }) - } + window.addEventListener('keydown', (event) => { + if (event.key === 'F12') { + void invoke('open_dev_tools') + } + if ((event.ctrlKey || event.metaKey) && event.shiftKey && (event.key === 'I' || event.key === 'i')) { + void invoke('open_dev_tools') + } + }) useAutoAction().init() useBiliFunction().init() @@ -340,6 +380,7 @@ export async function initAll(isOnBoot: boolean) { } clientInited.value = true + clientInitStage.value = '启动完成' } export function OnClientUnmounted() { if (clientInited.value) { diff --git a/src/components.d.ts b/src/components.d.ts index 7ccd2f9..36d64ee 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -18,14 +18,14 @@ declare module 'vue' { LabelItem: typeof import('./components/LabelItem.vue')['default'] LiveInfoContainer: typeof import('./components/LiveInfoContainer.vue')['default'] MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default'] - NAlert: typeof import('naive-ui')['NAlert'] - NEllipsis: typeof import('naive-ui')['NEllipsis'] - NEmpty: typeof import('naive-ui')['NEmpty'] + NAvatar: typeof import('naive-ui')['NAvatar'] + NButton: typeof import('naive-ui')['NButton'] + NCard: typeof import('naive-ui')['NCard'] NFlex: typeof import('naive-ui')['NFlex'] - NFormItemGi: typeof import('naive-ui')['NFormItemGi'] - NGridItem: typeof import('naive-ui')['NGridItem'] NIcon: typeof import('naive-ui')['NIcon'] - NScrollbar: typeof import('naive-ui')['NScrollbar'] + NImage: typeof import('naive-ui')['NImage'] + NPopconfirm: typeof import('naive-ui')['NPopconfirm'] + NSpace: typeof import('naive-ui')['NSpace'] NTag: typeof import('naive-ui')['NTag'] NText: typeof import('naive-ui')['NText'] PointGoodsItem: typeof import('./components/manage/PointGoodsItem.vue')['default'] diff --git a/src/views/manage/HistoryView.vue b/src/views/manage/HistoryView.vue index 1fd1897..33f9f0e 100644 --- a/src/views/manage/HistoryView.vue +++ b/src/views/manage/HistoryView.vue @@ -19,6 +19,7 @@ import VChart from 'vue-echarts' import { useAccount } from '@/api/account' import { QueryGetAPI } from '@/api/query' import { HISTORY_API_URL } from '@/data/constants' +import { GuidUtils } from '@/Utils' // 初始化ECharts组件 use([ @@ -133,6 +134,9 @@ const guardColumns: DataTableColumns = [ ellipsis: { tooltip: true, }, + render: (row) => { + return h('span', { style: { fontWeight: 'bold' } }, GuidUtils.isGuidFromUserId(row.guardOUId) ? GuidUtils.guidToLong(row.guardOUId) : row.guardOUId) + }, }, { title: '用户名', diff --git a/src/views/obs/DanmakuVoteOBS.vue b/src/views/obs/DanmakuVoteOBS.vue index 659be88..849ff3a 100644 --- a/src/views/obs/DanmakuVoteOBS.vue +++ b/src/views/obs/DanmakuVoteOBS.vue @@ -20,6 +20,22 @@ const voteData = ref(null) const fetchIntervalId = ref(null) const config = ref(null) const isLoading = ref(true) +const nowMs = ref(Date.now()) +const tickIntervalId = ref(null) + +const timeLeftMs = computed(() => { + if (!voteData.value?.endTime) return null + const remain = voteData.value.endTime * 1000 - nowMs.value + return Math.max(0, remain) +}) + +function formatRemain(ms: number | null | undefined) { + if (ms == null) return '' + const total = Math.floor(ms / 1000) + const mm = Math.floor(total / 60).toString().padStart(2, '0') + const ss = (total % 60).toString().padStart(2, '0') + return `${mm}:${ss}` +} // 可见性检测 const isVisible = computed(() => props.visible !== false) @@ -33,12 +49,14 @@ async function fetchVoteData() { const result = await QueryGetAPI(`${VOTE_API_URL}obs-data`, { user: userId }) - if (result.code === 0 && result.data) { + if (result.code === 200 && result.data) { voteData.value = result.data - // 更新每个选项的百分比 - if (voteData.value && voteData.value.options && voteData.value.totalVotes > 0) { + // 更新每个选项的百分比(若后端未提供) + if (voteData.value && voteData.value.options) { voteData.value.options.forEach((option) => { - option.percentage = calculatePercentage(option.count, voteData.value!.totalVotes) + if (option.percentage == null && voteData.value!.totalVotes > 0) { + option.percentage = calculatePercentage(option.count, voteData.value!.totalVotes) + } }) } } else if (voteData.value && !result.data) { @@ -82,7 +100,7 @@ async function fetchVoteConfig() { const result = await QueryGetAPI(`${VOTE_API_URL}get-config`, { user: userId }) - if (result.code === 0 && result.data) { + if (result.code === 200 && result.data) { config.value = result.data } } catch (error) { @@ -133,12 +151,19 @@ onMounted(async () => { // 获取投票配置和投票数据 await fetchVoteConfig() setupPolling() + // 本地计时器用于倒计时显示 + tickIntervalId.value = setInterval(() => { + nowMs.value = Date.now() + }, 1000) onUnmounted(() => { client.dispose() if (fetchIntervalId.value) { clearInterval(fetchIntervalId.value) } + if (tickIntervalId.value) { + clearInterval(tickIntervalId.value) + } }) }) @@ -163,6 +188,7 @@ onMounted(async () => { {{ voteData.title }} + 剩余 {{ formatRemain(timeLeftMs) }} diff --git a/src/views/open_live/DanmakuVote.vue b/src/views/open_live/DanmakuVote.vue index 74e3cf2..ce4a279 100644 --- a/src/views/open_live/DanmakuVote.vue +++ b/src/views/open_live/DanmakuVote.vue @@ -30,7 +30,7 @@ import { NThing, useMessage, } from 'naive-ui' -import { onMounted, onUnmounted, ref, watch } from 'vue' +import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { useRoute } from 'vue-router' import { clearInterval, setInterval } from 'worker-timers' import { useAccount } from '@/api/account' @@ -80,6 +80,21 @@ const isLoading = ref(false) const showSettingsModal = ref(false) const voteHistoryTab = ref([]) +const nowMs = ref(Date.now()) +const timeLeftMs = computed(() => { + if (!currentVote.value?.endTime) return null + const remain = currentVote.value.endTime * 1000 - nowMs.value + return Math.max(0, remain) +}) + +function formatRemain(ms: number | null | undefined) { + if (ms == null) return '--:--' + const total = Math.floor(ms / 1000) + const mm = Math.floor(total / 60).toString().padStart(2, '0') + const ss = (total % 60).toString().padStart(2, '0') + return `${mm}:${ss}` +} + // 创建投票相关 const newVoteTitle = ref('') const newVoteOptions = ref(['', '']) @@ -102,7 +117,7 @@ async function fetchVoteConfig() { isLoading.value = true const result = await QueryGetAPI(`${VOTE_API_URL}get-config`) - if (result.code === 0 && result.data) { + if (result.code === 200 && result.data) { voteConfig.value = result.data } } catch (error) { @@ -138,7 +153,7 @@ async function fetchActiveVote() { try { const result = await QueryGetAPI(`${VOTE_API_URL}get-active`) - if (result.code === 0) { + if (result.code === 200) { currentVote.value = result.data } } catch (error) { @@ -151,7 +166,7 @@ async function fetchVoteHistory() { try { const result = await QueryGetAPI(`${VOTE_API_URL}history`, { limit: 10, offset: 0 }) - if (result.code === 0) { + if (result.code === 200) { voteHistoryTab.value = result.data } } catch (error) { @@ -273,7 +288,7 @@ async function fetchVoteHash(): Promise { try { const result = await QueryGetAPI(`${VOTE_API_URL}get-hash`) - if (result.code === 0 && result.data) { + if (result.code === 200 && result.data) { return result.data } return null @@ -300,6 +315,24 @@ function loadTemplate(template: { title: string, options: string[] }) { } } +// 导入默认选项 +function importDefaultOptions() { + const opts = voteConfig.value.defaultOptions || [] + newVoteOptions.value = [...opts] + while (newVoteOptions.value.length < 2) { + newVoteOptions.value.push('') + } +} + +// 从历史复刻 +function reuseVote(vote: ResponseVoteSession) { + newVoteTitle.value = vote.title + newVoteOptions.value = vote.options.map(o => o.text) + while (newVoteOptions.value.length < 2) { + newVoteOptions.value.push('') + } +} + // 初始化和轮询 onMounted(async () => { // 初始化弹幕客户端 @@ -317,8 +350,13 @@ onMounted(async () => { await fetchActiveVote() }, 5000) + const tickInterval = setInterval(() => { + nowMs.value = Date.now() + }, 1000) + onUnmounted(() => { clearInterval(pollInterval) + clearInterval(tickInterval) client.dispose() }) }) @@ -423,12 +461,17 @@ function deleteTemplate(index: number) { > - - {{ currentVote.title }} - + + + {{ currentVote.title }} + + + 剩余: {{ formatRemain(timeLeftMs) }} + + 添加选项 + + 导入默认选项 + @@ -630,13 +676,22 @@ function deleteTemplate(index: number) { - - 删除 - + + + 复刻 + + + 删除 + +