This commit is contained in:
2026-04-03 09:55:36 +04:00
parent 2465bc2ec3
commit 27143319e3
22 changed files with 2542 additions and 173 deletions

View File

@@ -47,12 +47,15 @@
:final-group2="finalGroup2"
:final-rows="finalRows"
:final-tie="finalTie"
:score-for="scoreFor"
:final-total-score="finalTotalScore"
:preliminary-proof-stage="preliminaryProofStage"
:final-proof-stage="finalProofStage"
:podium-ordered="podiumOrdered"
:podium-image="podiumImage"
:podium-name="podiumName"
:podium-has-result="podiumHasResult"
:podium-score-display="podiumScoreDisplay"
:podium-score-main="podiumScoreMain"
:podium-tie-subtitle="podiumTieSubtitle"
:can-view-proofs="canViewProofs"
:has-score-proof="hasScoreProof"
:score-proof-for="scoreProofFor"
@@ -62,6 +65,32 @@
@open-proof="onOpenProofFromPayload"
/>
<LiveModePanel
v-else-if="mode === 'live'"
:t="t"
:live-settings="liveSettings"
:live-group-code="liveMonitorGroupCode"
:live-group-members="liveMonitorMembers"
:prelim-tie-rows="prelimTieRows"
:preliminary-rows="preliminaryRows"
:finalists="finalists"
:live-final-group="liveMonitorFinalGroup"
:live-final-rows="liveMonitorFinalRows"
:final-tie-rows="finalTieRows"
:podium-ordered="podiumOrdered"
:player-image="playerImage"
:display-name="displayName"
:secondary-name="secondaryName"
:score-for="scoreFor"
:prelim-total="preliminaryTotalScore"
:final-total="finalTotalScore"
:podium-image="podiumImage"
:podium-name="podiumName"
:podium-has-result="podiumHasResult"
:podium-score-main="podiumScoreMain"
:podium-tie-subtitle="podiumTieSubtitle"
/>
<template v-else>
<AdminLoginPanel
v-if="!adminToken"
@@ -80,6 +109,7 @@
:admin-tabs="adminTabs"
:admin-tab="adminTab"
:view-proof-in-view="canViewProofs"
:live-settings="liveSettings"
:group-setup-input="groupSetupInput"
:admin-group-cards="adminGroupCards"
:new-player="newPlayer"
@@ -90,12 +120,13 @@
:player-image="playerImage"
:group-option-label="groupOptionLabel"
:normalized-group-code="normalizedGroupCode"
:preliminary-rows="preliminaryRows"
:prelim-tie-rows="prelimTieRows"
:final-group1="finalGroup1"
:final-group2="finalGroup2"
:final-tie-rows="finalTieRows"
:preliminary-score-rows="adminPreliminaryRows"
:prelim-tie-score-rows="adminPrelimTieRows"
:final-score-rows="adminFinalRows"
:final-tie-score-rows="adminFinalTieRows"
:final-rows="finalRows"
:preliminary-total-score="preliminaryTotalScore"
:final-total-score="finalTotalScore"
:prelim-tie="prelimTie"
:final-tie="finalTie"
:score-filters="scoreFilters"
@@ -114,6 +145,7 @@
@refresh="fetchState()"
@logout="adminLogout"
@toggle-view-proof="updateViewProofSetting"
@update-live-setting="updateLiveModeSetting"
@change-admin-tab="adminTab = $event"
@update:group-setup-input="groupSetupInput = $event"
@save-group-setup="saveGroupSetup"
@@ -168,6 +200,7 @@
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import AppHeader from './components/AppHeader.vue'
import ViewModePanel from './components/ViewModePanel.vue'
import LiveModePanel from './components/LiveModePanel.vue'
import AdminLoginPanel from './components/AdminLoginPanel.vue'
import AdminPanel from './components/AdminPanel.vue'
import ProofModal from './components/ProofModal.vue'
@@ -181,9 +214,11 @@ import { convertNameAuto } from './utils/nameTransliteration'
const API_BASE = import.meta.env.VITE_API_BASE || '/api'
const FALLBACK_SYNC_MS = 15000
const STREAM_RETRY_MS = 1500
const PRELIM_ROUND_STAGES = ['prelim_r1', 'prelim_r2', 'prelim_r3']
const FINAL_ROUND_STAGES = ['final_r1', 'final_r2']
const loading = ref(true)
const mode = ref('view')
const mode = ref('live')
const viewTab = ref('groups')
const adminTab = ref('players')
const language = ref(localStorage.getItem('shooting_lang') === 'en' ? 'en' : 'ar')
@@ -204,8 +239,8 @@ const playerFilter = ref('')
const playerSort = ref('id')
const scoreFilters = reactive({
preliminary: '',
final_common: '',
prelim_tiebreak: '',
final: '',
final_tiebreak: '',
})
@@ -214,6 +249,10 @@ const liveMode = ref('rotate')
const selectedLiveGroup = ref('')
const liveTick = ref(0)
let liveTimer = null
const liveMonitorGroupIndex = ref(0)
const liveMonitorFinalIndex = ref(0)
let liveMonitorTimer = null
let syncTimer = null
let stream = null
let streamRetryTimer = null
@@ -256,6 +295,27 @@ const podiumRows = computed(() => state.value.derived?.podium || [])
const prelimTie = computed(() => state.value.derived?.preliminaryRanking?.tieBreak || { required: false, resolved: true, slots: 0, playerIds: [] })
const finalTie = computed(() => state.value.derived?.finalRanking?.tieBreak || { required: false, resolved: true, slots: 0, playerIds: [] })
const canViewProofs = computed(() => Boolean(state.value.settings?.viewProofInView))
const liveSettings = computed(() => {
const raw = state.value.settings?.liveMode || {}
const groupDisplayMode = raw.groupDisplayMode === 'fixed' ? 'fixed' : 'rotate'
const finalDisplayMode = raw.finalDisplayMode === 'fixed' ? 'fixed' : 'rotate'
const activeView = normalizeLiveActiveView(raw.activeView || 'group_live')
return {
activeView,
showGroupLive: raw.showGroupLive !== false,
groupDisplayMode,
groupFixedCode: normalizedGroupCode(raw.groupFixedCode || ''),
showPrelimTie: raw.showPrelimTie !== false,
showPrelimOverall: raw.showPrelimOverall !== false,
showFinalGroups: raw.showFinalGroups !== false,
finalDisplayMode,
finalFixedGroup: Number(raw.finalFixedGroup) === 2 ? 2 : 1,
showFinalTie: raw.showFinalTie !== false,
showPodium: raw.showPodium !== false,
rotationIntervalSec: Number(raw.rotationIntervalSec) > 0 ? Number(raw.rotationIntervalSec) : 5,
rotationPlayerCount: Number(raw.rotationPlayerCount) > 0 ? Number(raw.rotationPlayerCount) : 12,
}
})
const viewTabs = computed(() => [
{ id: 'groups', label: t('tabs.groups') },
@@ -267,6 +327,7 @@ const viewTabs = computed(() => [
const adminTabs = computed(() => [
{ id: 'players', label: t('tabs.players') },
{ id: 'liveMode', label: t('tabs.liveModeAdmin') },
{ id: 'preliminary', label: t('tabs.preliminary') },
{ id: 'prelimTie', label: t('tabs.prelimTie') },
{ id: 'final', label: t('tabs.final') },
@@ -346,6 +407,28 @@ const liveMembers = computed(() => {
if (!code) return []
return playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code)
})
const liveMonitorGroupRotationCodes = computed(() => activeGroupCodes.value)
const liveMonitorGroupCode = computed(() => {
if (liveSettings.value.groupDisplayMode === 'fixed') {
const fixed = normalizedGroupCode(liveSettings.value.groupFixedCode)
if (fixed) return fixed
return liveMonitorGroupRotationCodes.value[0] || ''
}
return liveMonitorGroupRotationCodes.value[liveMonitorGroupIndex.value] || ''
})
const liveMonitorMembers = computed(() => {
const code = normalizedGroupCode(liveMonitorGroupCode.value)
if (!code) return []
return playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code)
})
const liveMonitorFinalGroup = computed(() => {
if (liveSettings.value.finalDisplayMode === 'fixed') {
return liveSettings.value.finalFixedGroup === 2 ? 2 : 1
}
return liveMonitorFinalIndex.value % 2 === 0 ? 1 : 2
})
const liveMonitorFinalRows = computed(() => (liveMonitorFinalGroup.value === 2 ? finalGroup2.value : finalGroup1.value))
const podiumOrdered = computed(() => [podiumRows.value[1] || null, podiumRows.value[0] || null, podiumRows.value[2] || null])
const prelimTieRows = computed(() => {
@@ -358,6 +441,123 @@ const finalTieRows = computed(() => {
return finalRows.value.filter((row) => ids.has(row.playerId))
})
const playerByID = computed(() => {
const map = new Map()
for (const player of playersSorted.value) {
map.set(player.id, player)
}
return map
})
const preliminaryRowByID = computed(() => {
const map = new Map()
for (const row of preliminaryRows.value) {
map.set(row.playerId, row)
}
return map
})
const finalRowByID = computed(() => {
const map = new Map()
for (const row of finalRows.value) {
map.set(row.playerId, row)
}
return map
})
const adminPreliminaryRows = computed(() => {
const rows = playersSorted.value.map((player) => {
const derived = preliminaryRowByID.value.get(player.id)
return {
playerId: player.id,
nameAr: player.nameAr,
nameEn: player.nameEn,
groupCode: player.groupCode,
imageData: player.imageData,
score: derived?.score ?? preliminaryTotalScore(player.id),
tieBreak: derived?.tieBreak ?? 0,
rank: derived?.rank ?? 0,
}
})
rows.sort(compareRowsByGroupThenId)
return rows
})
const adminPrelimTieRows = computed(() => {
const rows = (prelimTie.value.playerIds || [])
.map((playerId) => {
const player = playerByID.value.get(playerId)
if (!player) return null
const derived = preliminaryRowByID.value.get(playerId)
return {
playerId,
nameAr: player.nameAr,
nameEn: player.nameEn,
groupCode: player.groupCode,
imageData: player.imageData,
score: derived?.score ?? preliminaryTotalScore(playerId),
tieBreak: derived?.tieBreak ?? 0,
}
})
.filter(Boolean)
rows.sort(compareRowsByScoreGroupThenId)
return rows
})
const adminFinalRows = computed(() => {
const rows = [...finalGroup1.value, ...finalGroup2.value].map((row) => {
const player = playerByID.value.get(row.playerId)
const ranked = finalRowByID.value.get(row.playerId)
return {
playerId: row.playerId,
nameAr: player?.nameAr || row.nameAr,
nameEn: player?.nameEn || row.nameEn,
groupCode: player?.groupCode || row.groupCode,
imageData: player?.imageData || row.imageData,
finalGroup: row.finalGroup,
seed: row.seed,
score: ranked?.score ?? row.score ?? 0,
tieBreak: ranked?.tieBreak ?? row.tieBreak ?? 0,
}
})
rows.sort((a, b) => {
const groupA = Number(a.finalGroup || 0)
const groupB = Number(b.finalGroup || 0)
if (groupA !== groupB) return groupA - groupB
const seedA = Number(a.seed || 0)
const seedB = Number(b.seed || 0)
if (seedA !== seedB) return seedA - seedB
return a.playerId - b.playerId
})
return rows
})
const adminFinalTieRows = computed(() => {
const rows = (finalTie.value.playerIds || [])
.map((playerId) => {
const player = playerByID.value.get(playerId)
if (!player) return null
const derived = finalRowByID.value.get(playerId)
return {
playerId,
nameAr: player.nameAr,
nameEn: player.nameEn,
groupCode: player.groupCode,
imageData: player.imageData,
finalGroup: derived?.finalGroup ?? 0,
seed: derived?.seed ?? 0,
score: derived?.score ?? finalTotalScore(playerId),
tieBreak: derived?.tieBreak ?? 0,
}
})
.filter(Boolean)
rows.sort(compareRowsByScoreGroupThenId)
return rows
})
watch(language, (value) => {
localStorage.setItem('shooting_lang', value)
document.documentElement.lang = value
@@ -375,6 +575,18 @@ watch(
},
)
watch(
() =>
`${mode.value}|${liveSettings.value.activeView}|${liveSettings.value.groupDisplayMode}|${liveSettings.value.finalDisplayMode}|${liveSettings.value.rotationIntervalSec}|${liveMonitorGroupRotationCodes.value.join('|')}|${finalGroup1.value.length}|${finalGroup2.value.length}`,
() => {
if (mode.value === 'live') {
startLiveMonitorRotation()
return
}
stopLiveMonitorRotation()
},
)
watch(
() => liveSelectableGroups.value.join('|'),
() => {
@@ -403,6 +615,30 @@ watch(
{ immediate: true },
)
watch(
() => liveMonitorGroupRotationCodes.value.join('|'),
() => {
if (liveMonitorGroupRotationCodes.value.length === 0) {
liveMonitorGroupIndex.value = 0
return
}
if (liveMonitorGroupIndex.value >= liveMonitorGroupRotationCodes.value.length) {
liveMonitorGroupIndex.value = 0
}
},
{ immediate: true },
)
watch(
() => `${finalGroup1.value.length}|${finalGroup2.value.length}`,
() => {
if (liveMonitorFinalIndex.value < 0) {
liveMonitorFinalIndex.value = 0
}
},
{ immediate: true },
)
onMounted(async () => {
document.documentElement.lang = language.value
document.documentElement.dir = language.value === 'ar' ? 'rtl' : 'ltr'
@@ -412,6 +648,7 @@ onMounted(async () => {
onBeforeUnmount(() => {
stopLiveRotation()
stopLiveMonitorRotation()
stopRealtimeSync()
if (toastTimer) clearTimeout(toastTimer)
})
@@ -425,6 +662,12 @@ function setLanguage(next) {
if (next === 'ar' || next === 'en') language.value = next
}
function normalizeLiveActiveView(value) {
const normalized = String(value || '').trim().toLowerCase()
const allowed = new Set(['group_live', 'prelim_tie', 'prelim_overall', 'final_groups', 'final_tie', 'podium'])
return allowed.has(normalized) ? normalized : 'group_live'
}
function formatServerTime(iso) {
try {
return new Intl.DateTimeFormat(language.value === 'ar' ? 'ar-AE' : 'en-US', {
@@ -448,6 +691,39 @@ function groupOptionLabel(code) {
return `${normalized} (${count})`
}
function groupOrderValue(code) {
const normalized = normalizedGroupCode(code)
if (!normalized) return Number.MAX_SAFE_INTEGER - 1
const idx = assignableGroups.value.indexOf(normalized)
if (idx >= 0) return idx
return Number.MAX_SAFE_INTEGER
}
function compareRowsByGroupThenId(a, b) {
const orderA = groupOrderValue(a.groupCode)
const orderB = groupOrderValue(b.groupCode)
if (orderA !== orderB) return orderA - orderB
const groupA = normalizedGroupCode(a.groupCode)
const groupB = normalizedGroupCode(b.groupCode)
if (groupA !== groupB) {
if (!groupA) return 1
if (!groupB) return -1
return groupA.localeCompare(groupB)
}
const idA = Number(a.playerId || a.id || 0)
const idB = Number(b.playerId || b.id || 0)
return idA - idB
}
function compareRowsByScoreGroupThenId(a, b) {
const scoreA = Number(a.score || 0)
const scoreB = Number(b.score || 0)
if (scoreA !== scoreB) return scoreB - scoreA
return compareRowsByGroupThenId(a, b)
}
function saveGroupSetup() {
const parsed = parseGroupList(groupSetupInput.value)
primaryGroups.value = parsed.length > 0 ? parsed : DEFAULT_PRIMARY_GROUPS
@@ -628,12 +904,12 @@ async function adminLogout() {
await fetchState(false)
}
async function updateViewProofSetting(enabled) {
async function updateAdminSettings(payload) {
try {
state.value = await api('/admin/settings', {
method: 'PUT',
admin: true,
body: { viewProofInView: Boolean(enabled) },
body: payload,
})
showToast(t('messages.saved'))
} catch (error) {
@@ -641,6 +917,15 @@ async function updateViewProofSetting(enabled) {
}
}
async function updateViewProofSetting(enabled) {
await updateAdminSettings({ viewProofInView: Boolean(enabled) })
}
async function updateLiveModeSetting(patch) {
if (!patch || typeof patch !== 'object') return
await updateAdminSettings({ liveMode: patch })
}
async function createPlayer() {
if (!newPlayer.nameAr || !newPlayer.nameEn) {
showToast(t('messages.mustProvideNames'), 'error')
@@ -971,6 +1256,33 @@ function scoreFor(stage, playerId) {
return state.value.scores?.[stage]?.[String(playerId)] ?? 0
}
function totalScoreByStages(stages, playerId) {
return stages.reduce((sum, stage) => sum + Number(scoreFor(stage, playerId) || 0), 0)
}
function preliminaryTotalScore(playerId) {
return totalScoreByStages(PRELIM_ROUND_STAGES, playerId)
}
function finalTotalScore(playerId) {
return totalScoreByStages(FINAL_ROUND_STAGES, playerId)
}
function proofStageByFamily(stages, playerId) {
for (const stage of stages) {
if (hasScoreProof(stage, playerId)) return stage
}
return ''
}
function preliminaryProofStage(playerId) {
return proofStageByFamily([...PRELIM_ROUND_STAGES].reverse(), playerId)
}
function finalProofStage(playerId) {
return proofStageByFamily([...FINAL_ROUND_STAGES].reverse(), playerId)
}
function scoreDraftKey(stage, playerId) {
return `${stage}:${playerId}`
}
@@ -1056,6 +1368,7 @@ function closeResetStageModal() {
async function confirmResetStage(resetProofs) {
const stage = resetModal.stage
if (!stage) return
const targets = resetStageTargets(stage)
try {
state.value = await api(`/admin/scores/${stage}/reset`, {
@@ -1064,11 +1377,11 @@ async function confirmResetStage(resetProofs) {
body: { resetProofs: Boolean(resetProofs) },
})
for (const key of Object.keys(scoreDrafts)) {
if (key.startsWith(`${stage}:`)) delete scoreDrafts[key]
if (targets.some((target) => key.startsWith(`${target}:`))) delete scoreDrafts[key]
}
if (resetProofs) {
for (const key of Object.keys(scoreAdviceCache)) {
if (key.startsWith(`${stage}:`)) delete scoreAdviceCache[key]
if (targets.some((target) => key.startsWith(`${target}:`))) delete scoreAdviceCache[key]
}
}
closeResetStageModal()
@@ -1078,6 +1391,12 @@ async function confirmResetStage(resetProofs) {
}
}
function resetStageTargets(stage) {
if (stage === 'preliminary') return PRELIM_ROUND_STAGES
if (stage === 'final') return FINAL_ROUND_STAGES
return [stage]
}
function isFinalist(playerId) {
return finalists.value.some((row) => row.playerId === playerId)
}
@@ -1104,6 +1423,36 @@ function stopLiveRotation() {
}
}
function startLiveMonitorRotation() {
stopLiveMonitorRotation()
const canRotateGroups =
liveSettings.value.activeView === 'group_live' && liveSettings.value.groupDisplayMode === 'rotate' && liveMonitorGroupRotationCodes.value.length > 1
const canRotateFinal =
liveSettings.value.activeView === 'final_groups' && liveSettings.value.finalDisplayMode === 'rotate' && finalGroup1.value.length > 0 && finalGroup2.value.length > 0
if (!canRotateGroups && !canRotateFinal) return
liveMonitorGroupIndex.value = 0
liveMonitorFinalIndex.value = 0
const intervalMs = Math.max(3, Number(liveSettings.value.rotationIntervalSec) || 5) * 1000
liveMonitorTimer = setInterval(() => {
if (canRotateGroups) {
liveMonitorGroupIndex.value = (liveMonitorGroupIndex.value + 1) % liveMonitorGroupRotationCodes.value.length
}
if (canRotateFinal) {
liveMonitorFinalIndex.value = (liveMonitorFinalIndex.value + 1) % 2
}
}, intervalMs)
}
function stopLiveMonitorRotation() {
if (liveMonitorTimer) {
clearInterval(liveMonitorTimer)
liveMonitorTimer = null
}
}
function buildStreamURL() {
const base = API_BASE.endsWith('/') ? API_BASE.slice(0, -1) : API_BASE
return `${base}/events`
@@ -1181,15 +1530,14 @@ function podiumHasResult(entry) {
return Boolean(entry && entry.score > 0)
}
function podiumScoreDisplay(entry) {
function podiumScoreMain(entry) {
if (!podiumHasResult(entry)) return '-'
return podiumScoreText(entry)
return `${t('table.score')}: ${entry.score}`
}
function podiumScoreText(entry) {
if (!entry || entry.score === 0) return ''
const scoreText = `${t('table.score')}: ${entry.score}`
if (!finalTie.value.required) return scoreText
return `${scoreText} · ${t('table.tieScore')}: ${entry.tieBreak}`
function podiumTieSubtitle(entry) {
if (!podiumHasResult(entry)) return ''
if (!finalTie.value.required) return ''
return `${t('table.tieScore')}: ${entry.tieBreak}`
}
</script>