generated from VLADIMIR/template_frontend
up
This commit is contained in:
@@ -19,7 +19,7 @@ import {
|
||||
type UploadFileInfo,
|
||||
} from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { getAuthClient } from '@/api/auth_client'
|
||||
@@ -58,6 +58,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import { applicationFileType, applicationFileURL } from '@/utils/application'
|
||||
import { formatGameDateTime, parseDateWithoutTimezone } from '@/utils/datetime'
|
||||
import { hasNewTeamApplications } from '@/utils/teamApplications'
|
||||
|
||||
const emit = defineEmits<{ resultsApplied: [] }>()
|
||||
|
||||
@@ -128,6 +129,12 @@ const game = ref<Game>({
|
||||
const authStore = useAuthStore()
|
||||
const uiStore = useUIStore()
|
||||
const canManageGame = computed(() => authStore.hasAnyRole(['admin', 'organizer', 'author']))
|
||||
let gameRequestVersion = 0
|
||||
let appliedGameRequestVersion = 0
|
||||
let hasLoadedGame = false
|
||||
let isDisposed = false
|
||||
let notificationAudio: AudioContext | undefined
|
||||
let hasReportedSoundError = false
|
||||
const canViewState = computed(
|
||||
() =>
|
||||
uiStore.environment === 'venue' &&
|
||||
@@ -220,10 +227,80 @@ const mapDragging = ref(false)
|
||||
const screenVolume = ref(100)
|
||||
const screenPosition = ref(0)
|
||||
|
||||
function reportSoundError(error: unknown) {
|
||||
if (isDisposed || hasReportedSoundError) return
|
||||
hasReportedSoundError = true
|
||||
message.warning('Не удалось включить звуковые уведомления о выдаче улик')
|
||||
console.error('Звуковое уведомление о выдаче улик', error)
|
||||
}
|
||||
|
||||
async function enableNotificationSound() {
|
||||
if (!canManageGame.value || isDisposed) return
|
||||
try {
|
||||
notificationAudio ??= new AudioContext()
|
||||
if (notificationAudio.state === 'suspended') await notificationAudio.resume()
|
||||
} catch (error) {
|
||||
reportSoundError(error)
|
||||
}
|
||||
}
|
||||
|
||||
function playApplicationNotification() {
|
||||
// До первого взаимодействия браузер блокирует звук; пропущенные сигналы не накапливаем.
|
||||
if (!notificationAudio || notificationAudio.state !== 'running') return
|
||||
try {
|
||||
const oscillator = notificationAudio.createOscillator()
|
||||
const gain = notificationAudio.createGain()
|
||||
const now = notificationAudio.currentTime
|
||||
oscillator.frequency.value = 880
|
||||
gain.gain.setValueAtTime(0, now)
|
||||
gain.gain.linearRampToValueAtTime(0.1, now + 0.02)
|
||||
gain.gain.linearRampToValueAtTime(0, now + 0.3)
|
||||
oscillator.connect(gain)
|
||||
gain.connect(notificationAudio.destination)
|
||||
oscillator.onended = () => {
|
||||
oscillator.disconnect()
|
||||
gain.disconnect()
|
||||
}
|
||||
oscillator.start(now)
|
||||
oscillator.stop(now + 0.3)
|
||||
} catch (error) {
|
||||
reportSoundError(error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('click', enableNotificationSound)
|
||||
window.addEventListener('keydown', enableNotificationSound)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
isDisposed = true
|
||||
window.removeEventListener('click', enableNotificationSound)
|
||||
window.removeEventListener('keydown', enableNotificationSound)
|
||||
void notificationAudio?.close().catch((error: unknown) => {
|
||||
console.error('Закрытие звуковых уведомлений', error)
|
||||
})
|
||||
})
|
||||
|
||||
async function getGame(id: number) {
|
||||
const requestVersion = ++gameRequestVersion
|
||||
const wasManaging = canManageGame.value
|
||||
const res = await client.GetGame({ ID: id })
|
||||
if (wasManaging !== canManageGame.value) return
|
||||
if (
|
||||
isDisposed ||
|
||||
wasManaging !== canManageGame.value ||
|
||||
requestVersion < appliedGameRequestVersion
|
||||
) {
|
||||
return
|
||||
}
|
||||
appliedGameRequestVersion = requestVersion
|
||||
if (
|
||||
canManageGame.value &&
|
||||
hasLoadedGame &&
|
||||
hasNewTeamApplications(game.value.teams || [], res.game?.teams || [])
|
||||
) {
|
||||
playApplicationNotification()
|
||||
}
|
||||
hasLoadedGame = true
|
||||
game.value = res.game!
|
||||
if (!settingsDraftInitialized.value) {
|
||||
settingsDraft.value = {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import type { Team } from '../../api/generated/crabs/evening_detective_server/index.ts'
|
||||
import { hasNewTeamApplications } from '../teamApplications.ts'
|
||||
|
||||
function team(ID: string, names: string[]): Team {
|
||||
return {
|
||||
ID,
|
||||
name: undefined,
|
||||
status: undefined,
|
||||
password: undefined,
|
||||
actionsCount: undefined,
|
||||
applications: names.map((name) => ({ name, image: undefined, file_type: undefined })),
|
||||
captainEmail: undefined,
|
||||
participantsCount: undefined,
|
||||
participationStatus: undefined,
|
||||
participationSource: undefined,
|
||||
participationEmailError: undefined,
|
||||
participationEmailSent: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
for (const scenario of [
|
||||
{ name: 'empty lists', previous: [], current: [], expected: false },
|
||||
{
|
||||
name: 'new application',
|
||||
previous: [team('a', [])],
|
||||
current: [team('a', ['clue'])],
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: 'unchanged applications and reordered teams',
|
||||
previous: [team('a', ['one', 'two']), team('b', ['clue'])],
|
||||
current: [team('b', ['clue']), team('a', ['two', 'one'])],
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: 'issued application',
|
||||
previous: [team('a', ['clue'])],
|
||||
current: [team('a', [])],
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: 'replacement with unchanged count',
|
||||
previous: [team('a', ['one'])],
|
||||
current: [team('a', ['two'])],
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: 'same application for another team',
|
||||
previous: [team('a', ['clue']), team('b', [])],
|
||||
current: [team('a', ['clue']), team('b', ['clue'])],
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: 'multiple new applications',
|
||||
previous: [],
|
||||
current: [team('a', ['one', 'two']), team('b', ['clue'])],
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: 'team and application names cannot collide',
|
||||
previous: [team('a:b', ['c'])],
|
||||
current: [team('a', ['b:c'])],
|
||||
expected: true,
|
||||
},
|
||||
]) {
|
||||
test(scenario.name, () => {
|
||||
assert.equal(hasNewTeamApplications(scenario.previous, scenario.current), scenario.expected)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Team } from '@/api/generated/crabs/evening_detective_server'
|
||||
|
||||
export function hasNewTeamApplications(previous: Team[], current: Team[]): boolean {
|
||||
const previousKeys = new Set(
|
||||
previous.flatMap((team) =>
|
||||
(team.applications || []).map((application) => JSON.stringify([team.ID, application.name])),
|
||||
),
|
||||
)
|
||||
return current.some((team) =>
|
||||
team.applications?.some(
|
||||
(application) => !previousKeys.has(JSON.stringify([team.ID, application.name])),
|
||||
),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user