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
roundedCorners: boolean
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 { 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(() => {
<template>
<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
v-if="!isLoggedIn"
class="login-container"
@@ -273,12 +285,12 @@ onMounted(() => {
>
<div style="padding: 12px; padding-right: 15px;">
<RouterView v-slot="{ Component }">
<Transition
name="fade-slide"
mode="out-in"
:appear="true"
>
<KeepAlive>
<KeepAlive>
<Transition
name="fade-slide"
mode="out-in"
:appear="true"
>
<Suspense>
<component :is="Component" />
<template #fallback>
@@ -287,8 +299,8 @@ onMounted(() => {
</div>
</template>
</Suspense>
</KeepAlive>
</Transition>
</Transition>
</KeepAlive>
</RouterView>
</div>
</NLayoutContent>
@@ -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;
}
</style>

View File

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

12
src/components.d.ts vendored
View File

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

View File

@@ -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<GuardMemberModel> = [
ellipsis: {
tooltip: true,
},
render: (row) => {
return h('span', { style: { fontWeight: 'bold' } }, GuidUtils.isGuidFromUserId(row.guardOUId) ? GuidUtils.guidToLong(row.guardOUId) : row.guardOUId)
},
},
{
title: '用户名',

View File

@@ -20,6 +20,22 @@ const voteData = ref<VoteOBSData | null>(null)
const fetchIntervalId = ref<number | null>(null)
const config = ref<VoteConfig | null>(null)
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)
@@ -33,12 +49,14 @@ async function fetchVoteData() {
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
// 更新每个选项的百分比
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<VoteConfig>(`${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)
}
})
})
</script>
@@ -163,6 +188,7 @@ onMounted(async () => {
<div class="vote-header">
<div class="vote-title">
{{ voteData.title }}
<span v-if="timeLeftMs !== null" class="vote-timer">剩余 {{ formatRemain(timeLeftMs) }}</span>
</div>
</div>

View File

@@ -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<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 newVoteOptions = ref<string[]>(['', ''])
@@ -102,7 +117,7 @@ async function fetchVoteConfig() {
isLoading.value = true
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
}
} catch (error) {
@@ -138,7 +153,7 @@ async function fetchActiveVote() {
try {
const result = await QueryGetAPI<ResponseVoteSession>(`${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<ResponseVoteSession[]>(`${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<string | null> {
try {
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 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) {
>
<NSpace vertical>
<NSpace justify="space-between">
<NText
strong
style="font-size: 1.2em"
>
{{ currentVote.title }}
</NText>
<NSpace>
<NText
strong
style="font-size: 1.2em"
>
{{ currentVote.title }}
</NText>
<NTag v-if="timeLeftMs !== null" type="warning">
剩余: {{ formatRemain(timeLeftMs) }}
</NTag>
</NSpace>
<NButton
type="warning"
@click="endVote"
@@ -506,6 +549,9 @@ function deleteTemplate(index: number) {
</template>
添加选项
</NButton>
<NButton secondary @click="importDefaultOptions">
导入默认选项
</NButton>
</NSpace>
<NSpace>
@@ -630,13 +676,22 @@ function deleteTemplate(index: number) {
</NSpace>
</template>
<template #action>
<NButton
size="small"
type="error"
@click="deleteVote(vote.id)"
>
删除
</NButton>
<NSpace>
<NButton
size="small"
type="primary"
@click="reuseVote(vote)"
>
复刻
</NButton>
<NButton
size="small"
type="error"
@click="deleteVote(vote.id)"
>
删除
</NButton>
</NSpace>
</template>
</NThing>
</NListItem>