feat: add vote countdown timer and improve initialization UI

This commit is contained in:
2025-10-17 20:30:04 +08:00
parent dfc1a9d709
commit 3582cbcf64
7 changed files with 220 additions and 51 deletions

View File

@@ -1018,4 +1018,5 @@ export interface VoteOBSData {
optionColor: string optionColor: string
roundedCorners: boolean roundedCorners: boolean
displayPosition: string displayPosition: string
endTime?: number
} }

View File

@@ -15,7 +15,7 @@ import { RouterLink, RouterView } from 'vue-router' // 引入 Vue Router 组件
import { ACCOUNT, GetSelfAccount, isLoadingAccount, isLoggedIn } from '@/api/account' import { ACCOUNT, GetSelfAccount, isLoadingAccount, isLoggedIn } from '@/api/account'
import { useWebFetcher } from '@/store/useWebFetcher' 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 { useDanmakuWindow } from './store/useDanmakuWindow'
// 引入子组件 // 引入子组件
import WindowBar from './WindowBar.vue' import WindowBar from './WindowBar.vue'
@@ -141,6 +141,18 @@ onMounted(() => {
<template> <template>
<WindowBar /> <WindowBar />
<Transition name="fade">
<div
v-if="isLoggedIn && !clientInited"
class="init-overlay"
>
<div class="init-overlay-content">
<NSpin size="large" />
<div class="init-stage">{{ clientInitStage || '初始化中...' }}</div>
</div>
</div>
</Transition>
<div <div
v-if="!isLoggedIn" v-if="!isLoggedIn"
class="login-container" class="login-container"
@@ -273,12 +285,12 @@ onMounted(() => {
> >
<div style="padding: 12px; padding-right: 15px;"> <div style="padding: 12px; padding-right: 15px;">
<RouterView v-slot="{ Component }"> <RouterView v-slot="{ Component }">
<Transition <KeepAlive>
name="fade-slide" <Transition
mode="out-in" name="fade-slide"
:appear="true" mode="out-in"
> :appear="true"
<KeepAlive> >
<Suspense> <Suspense>
<component :is="Component" /> <component :is="Component" />
<template #fallback> <template #fallback>
@@ -287,8 +299,8 @@ onMounted(() => {
</div> </div>
</template> </template>
</Suspense> </Suspense>
</KeepAlive> </Transition>
</Transition> </KeepAlive>
</RouterView> </RouterView>
</div> </div>
</NLayoutContent> </NLayoutContent>
@@ -455,4 +467,34 @@ onMounted(() => {
opacity: 0; opacity: 0;
transform: translateX(20px); 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;
}
</style> </style>

View File

@@ -14,7 +14,7 @@ import {
import { openUrl } from '@tauri-apps/plugin-opener' import { openUrl } from '@tauri-apps/plugin-opener'
import { relaunch } from '@tauri-apps/plugin-process' import { relaunch } from '@tauri-apps/plugin-process'
import { check } from '@tauri-apps/plugin-updater' import { check } from '@tauri-apps/plugin-updater'
import { h } from 'vue' import { h, ref } from 'vue'
import { isLoggedIn, useAccount } from '@/api/account' import { isLoggedIn, useAccount } from '@/api/account'
import { CN_HOST, isDev } from '@/data/constants' import { CN_HOST, isDev } from '@/data/constants'
import { useWebFetcher } from '@/store/useWebFetcher' import { useWebFetcher } from '@/store/useWebFetcher'
@@ -29,6 +29,7 @@ import { getBuvid, getRoomKey } from './utils'
const accountInfo = useAccount() const accountInfo = useAccount()
export const clientInited = ref(false) export const clientInited = ref(false)
export const clientInitStage = ref('')
let tray: TrayIcon let tray: TrayIcon
let heartbeatTimer: number | null = null let heartbeatTimer: number | null = null
let updateCheckTimer: number | null = null let updateCheckTimer: number | null = null
@@ -154,9 +155,23 @@ async function handleUpdateInstall(update: any) {
// 显示下载进度通知 // 显示下载进度通知
let downloaded = 0 let downloaded = 0
let contentLength = 0 let contentLength = 0
const progressPercentage = ref(0)
const progressText = ref('准备下载更新...')
const progressNotification = window.$notification.info({ const progressNotification = window.$notification.info({
title: '正在下载更新', 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, closable: false,
duration: 0, duration: 0,
}) })
@@ -167,17 +182,27 @@ async function handleUpdateInstall(update: any) {
case 'Started': case 'Started':
contentLength = event.data.contentLength || 0 contentLength = event.data.contentLength || 0
info(`[更新] 开始下载 ${contentLength} 字节`) info(`[更新] 开始下载 ${contentLength} 字节`)
progressPercentage.value = 0
progressText.value = '正在连接下载源...'
break break
case 'Progress': { case 'Progress': {
downloaded += event.data.chunkLength downloaded += event.data.chunkLength
const progress = contentLength > 0 ? Math.round((downloaded / contentLength) * 100) : 0 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} 字节`) info(`[更新] 已下载 ${downloaded} / ${contentLength} 字节`)
break break
} }
case 'Finished': case 'Finished':
info('[更新] 下载完成') info('[更新] 下载完成')
progressNotification.content = '下载完成,正在安装...' progressPercentage.value = 100
progressText.value = '下载完成,正在安装...'
break break
} }
}) })
@@ -217,6 +242,7 @@ export async function initAll(isOnBoot: boolean) {
if (clientInited.value) { if (clientInited.value) {
return return
} }
clientInitStage.value = '初始化中...'
// 初始检查更新(不阻塞初始化) // 初始检查更新(不阻塞初始化)
if (!isDev) { if (!isDev) {
void checkUpdate() void checkUpdate()
@@ -246,10 +272,13 @@ export async function initAll(isOnBoot: boolean) {
await attachConsole() await attachConsole()
const settings = useSettings() const settings = useSettings()
const biliCookie = useBiliCookie() const biliCookie = useBiliCookie()
clientInitStage.value = '加载设置...'
await settings.init() await settings.init()
info('[init] 已加载账户信息') info('[init] 已加载账户信息')
clientInitStage.value = '加载 Bilibili Cookie...'
biliCookie.init() biliCookie.init()
info('[init] 已加载bilibili cookie') info('[init] 已加载bilibili cookie')
clientInitStage.value = '初始化基础信息...'
initInfo() initInfo()
info('[init] 开始更新数据') info('[init] 开始更新数据')
@@ -258,6 +287,7 @@ export async function initAll(isOnBoot: boolean) {
title: '正在初始化弹幕客户端...', title: '正在初始化弹幕客户端...',
closable: false, closable: false,
}) })
clientInitStage.value = '初始化弹幕客户端...'
const result = await initDanmakuClient() const result = await initDanmakuClient()
danmakuInitNoticeRef.destroy() danmakuInitNoticeRef.destroy()
if (result.success) { if (result.success) {
@@ -265,16 +295,26 @@ export async function initAll(isOnBoot: boolean) {
title: '弹幕客户端初始化完成', title: '弹幕客户端初始化完成',
duration: 3000, duration: 3000,
}) })
clientInitStage.value = '弹幕客户端初始化完成'
} else { } else {
window.$notification.error({ window.$notification.error({
title: `弹幕客户端初始化失败: ${result.message}`, title: `弹幕客户端初始化失败: ${result.message}`,
}) })
clientInitStage.value = '弹幕客户端初始化失败'
} }
} }
info('[init] 已加载弹幕客户端') info('[init] 已加载弹幕客户端')
// 初始化系统托盘图标和菜单 // 初始化系统托盘图标和菜单
clientInitStage.value = '创建系统托盘...'
const menu = await Menu.new({ const menu = await Menu.new({
items: [ items: [
{
id: 'open-devtools',
text: '打开调试控制台',
action: () => {
void invoke('open_dev_tools')
},
},
{ {
id: 'quit', id: 'quit',
text: '退出', text: '退出',
@@ -299,6 +339,7 @@ export async function initAll(isOnBoot: boolean) {
}, },
} }
tray = await TrayIcon.new(options) tray = await TrayIcon.new(options)
clientInitStage.value = '系统托盘就绪'
appWindow.setMinSize(new PhysicalSize(720, 480)) appWindow.setMinSize(new PhysicalSize(720, 480))
@@ -319,15 +360,14 @@ export async function initAll(isOnBoot: boolean) {
} }
}) })
// 监听f12事件 window.addEventListener('keydown', (event) => {
if (!isDev) { if (event.key === 'F12') {
window.addEventListener('keydown', (event) => { void invoke('open_dev_tools')
if (event.key === 'F12') { }
event.preventDefault() if ((event.ctrlKey || event.metaKey) && event.shiftKey && (event.key === 'I' || event.key === 'i')) {
event.stopPropagation() void invoke('open_dev_tools')
} }
}) })
}
useAutoAction().init() useAutoAction().init()
useBiliFunction().init() useBiliFunction().init()
@@ -340,6 +380,7 @@ export async function initAll(isOnBoot: boolean) {
} }
clientInited.value = true clientInited.value = true
clientInitStage.value = '启动完成'
} }
export function OnClientUnmounted() { export function OnClientUnmounted() {
if (clientInited.value) { if (clientInited.value) {

12
src/components.d.ts vendored
View File

@@ -18,14 +18,14 @@ declare module 'vue' {
LabelItem: typeof import('./components/LabelItem.vue')['default'] LabelItem: typeof import('./components/LabelItem.vue')['default']
LiveInfoContainer: typeof import('./components/LiveInfoContainer.vue')['default'] LiveInfoContainer: typeof import('./components/LiveInfoContainer.vue')['default']
MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default'] MonacoEditorComponent: typeof import('./components/MonacoEditorComponent.vue')['default']
NAlert: typeof import('naive-ui')['NAlert'] NAvatar: typeof import('naive-ui')['NAvatar']
NEllipsis: typeof import('naive-ui')['NEllipsis'] NButton: typeof import('naive-ui')['NButton']
NEmpty: typeof import('naive-ui')['NEmpty'] NCard: typeof import('naive-ui')['NCard']
NFlex: typeof import('naive-ui')['NFlex'] NFlex: typeof import('naive-ui')['NFlex']
NFormItemGi: typeof import('naive-ui')['NFormItemGi']
NGridItem: typeof import('naive-ui')['NGridItem']
NIcon: typeof import('naive-ui')['NIcon'] 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'] NTag: typeof import('naive-ui')['NTag']
NText: typeof import('naive-ui')['NText'] NText: typeof import('naive-ui')['NText']
PointGoodsItem: typeof import('./components/manage/PointGoodsItem.vue')['default'] PointGoodsItem: typeof import('./components/manage/PointGoodsItem.vue')['default']

View File

@@ -19,6 +19,7 @@ import VChart from 'vue-echarts'
import { useAccount } from '@/api/account' import { useAccount } from '@/api/account'
import { QueryGetAPI } from '@/api/query' import { QueryGetAPI } from '@/api/query'
import { HISTORY_API_URL } from '@/data/constants' import { HISTORY_API_URL } from '@/data/constants'
import { GuidUtils } from '@/Utils'
// 初始化ECharts组件 // 初始化ECharts组件
use([ use([
@@ -133,6 +134,9 @@ const guardColumns: DataTableColumns<GuardMemberModel> = [
ellipsis: { ellipsis: {
tooltip: true, tooltip: true,
}, },
render: (row) => {
return h('span', { style: { fontWeight: 'bold' } }, GuidUtils.isGuidFromUserId(row.guardOUId) ? GuidUtils.guidToLong(row.guardOUId) : row.guardOUId)
},
}, },
{ {
title: '用户名', title: '用户名',

View File

@@ -20,6 +20,22 @@ const voteData = ref<VoteOBSData | null>(null)
const fetchIntervalId = ref<number | null>(null) const fetchIntervalId = ref<number | null>(null)
const config = ref<VoteConfig | null>(null) const config = ref<VoteConfig | null>(null)
const isLoading = ref(true) const isLoading = ref(true)
const nowMs = ref<number>(Date.now())
const tickIntervalId = ref<number | null>(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) const isVisible = computed(() => props.visible !== false)
@@ -33,12 +49,14 @@ async function fetchVoteData() {
const result = await QueryGetAPI<VoteOBSData>(`${VOTE_API_URL}obs-data`, { user: userId }) const result = await QueryGetAPI<VoteOBSData>(`${VOTE_API_URL}obs-data`, { user: userId })
if (result.code === 0 && result.data) { if (result.code === 200 && result.data) {
voteData.value = 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) => { 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) { } else if (voteData.value && !result.data) {
@@ -82,7 +100,7 @@ async function fetchVoteConfig() {
const result = await QueryGetAPI<VoteConfig>(`${VOTE_API_URL}get-config`, { user: userId }) const result = await QueryGetAPI<VoteConfig>(`${VOTE_API_URL}get-config`, { user: userId })
if (result.code === 0 && result.data) { if (result.code === 200 && result.data) {
config.value = result.data config.value = result.data
} }
} catch (error) { } catch (error) {
@@ -133,12 +151,19 @@ onMounted(async () => {
// 获取投票配置和投票数据 // 获取投票配置和投票数据
await fetchVoteConfig() await fetchVoteConfig()
setupPolling() setupPolling()
// 本地计时器用于倒计时显示
tickIntervalId.value = setInterval(() => {
nowMs.value = Date.now()
}, 1000)
onUnmounted(() => { onUnmounted(() => {
client.dispose() client.dispose()
if (fetchIntervalId.value) { if (fetchIntervalId.value) {
clearInterval(fetchIntervalId.value) clearInterval(fetchIntervalId.value)
} }
if (tickIntervalId.value) {
clearInterval(tickIntervalId.value)
}
}) })
}) })
</script> </script>
@@ -163,6 +188,7 @@ onMounted(async () => {
<div class="vote-header"> <div class="vote-header">
<div class="vote-title"> <div class="vote-title">
{{ voteData.title }} {{ voteData.title }}
<span v-if="timeLeftMs !== null" class="vote-timer">剩余 {{ formatRemain(timeLeftMs) }}</span>
</div> </div>
</div> </div>

View File

@@ -30,7 +30,7 @@ import {
NThing, NThing,
useMessage, useMessage,
} from 'naive-ui' } from 'naive-ui'
import { onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { clearInterval, setInterval } from 'worker-timers' import { clearInterval, setInterval } from 'worker-timers'
import { useAccount } from '@/api/account' import { useAccount } from '@/api/account'
@@ -80,6 +80,21 @@ const isLoading = ref(false)
const showSettingsModal = ref(false) const showSettingsModal = ref(false)
const voteHistoryTab = ref<ResponseVoteSession[]>([]) const voteHistoryTab = ref<ResponseVoteSession[]>([])
const nowMs = ref<number>(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 newVoteTitle = ref('')
const newVoteOptions = ref<string[]>(['', '']) const newVoteOptions = ref<string[]>(['', ''])
@@ -102,7 +117,7 @@ async function fetchVoteConfig() {
isLoading.value = true isLoading.value = true
const result = await QueryGetAPI<VoteConfig>(`${VOTE_API_URL}get-config`) const result = await QueryGetAPI<VoteConfig>(`${VOTE_API_URL}get-config`)
if (result.code === 0 && result.data) { if (result.code === 200 && result.data) {
voteConfig.value = result.data voteConfig.value = result.data
} }
} catch (error) { } catch (error) {
@@ -138,7 +153,7 @@ async function fetchActiveVote() {
try { try {
const result = await QueryGetAPI<ResponseVoteSession>(`${VOTE_API_URL}get-active`) const result = await QueryGetAPI<ResponseVoteSession>(`${VOTE_API_URL}get-active`)
if (result.code === 0) { if (result.code === 200) {
currentVote.value = result.data currentVote.value = result.data
} }
} catch (error) { } catch (error) {
@@ -151,7 +166,7 @@ async function fetchVoteHistory() {
try { try {
const result = await QueryGetAPI<ResponseVoteSession[]>(`${VOTE_API_URL}history`, { limit: 10, offset: 0 }) const result = await QueryGetAPI<ResponseVoteSession[]>(`${VOTE_API_URL}history`, { limit: 10, offset: 0 })
if (result.code === 0) { if (result.code === 200) {
voteHistoryTab.value = result.data voteHistoryTab.value = result.data
} }
} catch (error) { } catch (error) {
@@ -273,7 +288,7 @@ async function fetchVoteHash(): Promise<string | null> {
try { try {
const result = await QueryGetAPI<string>(`${VOTE_API_URL}get-hash`) const result = await QueryGetAPI<string>(`${VOTE_API_URL}get-hash`)
if (result.code === 0 && result.data) { if (result.code === 200 && result.data) {
return result.data return result.data
} }
return null 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 () => { onMounted(async () => {
// 初始化弹幕客户端 // 初始化弹幕客户端
@@ -317,8 +350,13 @@ onMounted(async () => {
await fetchActiveVote() await fetchActiveVote()
}, 5000) }, 5000)
const tickInterval = setInterval(() => {
nowMs.value = Date.now()
}, 1000)
onUnmounted(() => { onUnmounted(() => {
clearInterval(pollInterval) clearInterval(pollInterval)
clearInterval(tickInterval)
client.dispose() client.dispose()
}) })
}) })
@@ -423,12 +461,17 @@ function deleteTemplate(index: number) {
> >
<NSpace vertical> <NSpace vertical>
<NSpace justify="space-between"> <NSpace justify="space-between">
<NText <NSpace>
strong <NText
style="font-size: 1.2em" strong
> style="font-size: 1.2em"
{{ currentVote.title }} >
</NText> {{ currentVote.title }}
</NText>
<NTag v-if="timeLeftMs !== null" type="warning">
剩余: {{ formatRemain(timeLeftMs) }}
</NTag>
</NSpace>
<NButton <NButton
type="warning" type="warning"
@click="endVote" @click="endVote"
@@ -506,6 +549,9 @@ function deleteTemplate(index: number) {
</template> </template>
添加选项 添加选项
</NButton> </NButton>
<NButton secondary @click="importDefaultOptions">
导入默认选项
</NButton>
</NSpace> </NSpace>
<NSpace> <NSpace>
@@ -630,13 +676,22 @@ function deleteTemplate(index: number) {
</NSpace> </NSpace>
</template> </template>
<template #action> <template #action>
<NButton <NSpace>
size="small" <NButton
type="error" size="small"
@click="deleteVote(vote.id)" type="primary"
> @click="reuseVote(vote)"
删除 >
</NButton> 复刻
</NButton>
<NButton
size="small"
type="error"
@click="deleteVote(vote.id)"
>
删除
</NButton>
</NSpace>
</template> </template>
</NThing> </NThing>
</NListItem> </NListItem>