mirror of
https://github.com/Megghy/vtsuru.live.git
synced 2025-12-06 18:36:55 +08:00
feat: add vote countdown timer and improve initialization UI
This commit is contained in:
@@ -1018,4 +1018,5 @@ export interface VoteOBSData {
|
||||
optionColor: string
|
||||
roundedCorners: boolean
|
||||
displayPosition: string
|
||||
endTime?: number
|
||||
}
|
||||
|
||||
@@ -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 }">
|
||||
<KeepAlive>
|
||||
<Transition
|
||||
name="fade-slide"
|
||||
mode="out-in"
|
||||
:appear="true"
|
||||
>
|
||||
<KeepAlive>
|
||||
<Suspense>
|
||||
<component :is="Component" />
|
||||
<template #fallback>
|
||||
@@ -287,8 +299,8 @@ onMounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
</Suspense>
|
||||
</KeepAlive>
|
||||
</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>
|
||||
|
||||
@@ -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()
|
||||
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
12
src/components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -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: '用户名',
|
||||
|
||||
@@ -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) => {
|
||||
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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
<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,6 +676,14 @@ function deleteTemplate(index: number) {
|
||||
</NSpace>
|
||||
</template>
|
||||
<template #action>
|
||||
<NSpace>
|
||||
<NButton
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="reuseVote(vote)"
|
||||
>
|
||||
复刻
|
||||
</NButton>
|
||||
<NButton
|
||||
size="small"
|
||||
type="error"
|
||||
@@ -637,6 +691,7 @@ function deleteTemplate(index: number) {
|
||||
>
|
||||
删除
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NThing>
|
||||
</NListItem>
|
||||
|
||||
Reference in New Issue
Block a user