This commit is contained in:
2026-10-12 14:41:41 +07:00
parent c7ff9065eb
commit 3a2e574e97
3 changed files with 165 additions and 2 deletions
+79 -2
View File
@@ -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)
})
}
+14
View File
@@ -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])),
),
)
}