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
|
optionColor: string
|
||||||
roundedCorners: boolean
|
roundedCorners: boolean
|
||||||
displayPosition: string
|
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 { 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>
|
||||||
|
|||||||
@@ -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
12
src/components.d.ts
vendored
@@ -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']
|
||||||
|
|||||||
@@ -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: '用户名',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user