mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-05 00:46:00 +08:00
Persist learn progress per user
This commit is contained in:
21
app/middleware/require-auth.ts
Normal file
21
app/middleware/require-auth.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineNuxtRouteMiddleware, navigateTo } from '#app'
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const auth = useAuthStore()
|
||||
|
||||
if (!auth.accessToken) {
|
||||
await auth.tryRefresh().catch(() => false)
|
||||
}
|
||||
|
||||
if (!auth.initialized) {
|
||||
await auth.fetchUser().catch(() => null)
|
||||
} else if (!auth.user) {
|
||||
await auth.fetchUser().catch(() => null)
|
||||
}
|
||||
|
||||
if (!auth.user) {
|
||||
const redirect = encodeURIComponent(to.fullPath || '/')
|
||||
return navigateTo(`/login?redirect=${redirect}`)
|
||||
}
|
||||
})
|
||||
@@ -413,8 +413,12 @@
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { createDefaultLearnConfig } from '~~/shared/learn/config'
|
||||
import type { LearnConfig, LearnProgress, LearnState } from '~~/shared/learn/config'
|
||||
|
||||
definePageMeta({ middleware: 'require-auth' })
|
||||
|
||||
type BlankWidth = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
|
||||
@@ -1806,81 +1810,142 @@ const sayCache = new Map<string, string>()
|
||||
const pendingSayRequests = new Map<string, Promise<string>>()
|
||||
const audioReveal = ref(true)
|
||||
|
||||
const audioContentHidden = computed(() => cfg.value.audioChallenge && !audioReveal.value)
|
||||
|
||||
const toast = ref({ show: false, text: '' })
|
||||
const showSettings = ref(false)
|
||||
const api = useApi()
|
||||
|
||||
const isClient = typeof window !== 'undefined'
|
||||
|
||||
function readStorage<T>(key: string, fallback: T): T {
|
||||
if (!isClient) return fallback
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (!raw) return fallback
|
||||
const parsed = JSON.parse(raw) as T
|
||||
return parsed ?? fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function readNumber(key: string, fallback: number): number {
|
||||
if (!isClient) return fallback
|
||||
const raw = localStorage.getItem(key)
|
||||
if (!raw) return fallback
|
||||
const value = Number(raw)
|
||||
return Number.isFinite(value) ? value : fallback
|
||||
}
|
||||
|
||||
type LearnConfig = {
|
||||
tts: boolean
|
||||
radioLevel: number
|
||||
voice: string
|
||||
audioChallenge: boolean
|
||||
}
|
||||
|
||||
const defaultCfg: LearnConfig = { tts: false, radioLevel: 4, voice: '', audioChallenge: false }
|
||||
const defaultCfg = createDefaultLearnConfig()
|
||||
const cfg = ref<LearnConfig>({ ...defaultCfg })
|
||||
audioReveal.value = !cfg.value.audioChallenge
|
||||
|
||||
if (isClient) {
|
||||
const storedCfg = readStorage<{ tts?: boolean; audioChallenge?: boolean }>('os_cfg', {})
|
||||
const storedLevel = readStorage<{ v?: number }>('os_cfg_level', {})
|
||||
const storedVoice = readStorage<{ v?: string }>('os_cfg_voice', {})
|
||||
cfg.value = {
|
||||
tts: storedCfg.tts ?? defaultCfg.tts,
|
||||
radioLevel: storedLevel.v ?? defaultCfg.radioLevel,
|
||||
voice: storedVoice.v ?? defaultCfg.voice,
|
||||
audioChallenge: storedCfg.audioChallenge ?? defaultCfg.audioChallenge
|
||||
}
|
||||
}
|
||||
|
||||
audioReveal.value = !cfg.value.audioChallenge
|
||||
|
||||
const xp = ref(readNumber('os_xp', 0))
|
||||
const xp = ref(0)
|
||||
const progress = ref<LearnProgress>({})
|
||||
const level = computed(() => 1 + Math.floor(xp.value / 300))
|
||||
const seasonPct = computed(() => Math.min(100, Math.round((xp.value % 1000) / 10)))
|
||||
|
||||
type Prog = Record<string, Record<string, { best: number; done: boolean }>>
|
||||
const progress = ref<Prog>(readStorage<Prog>('os_progress', {} as Prog))
|
||||
const audioContentHidden = computed(() => cfg.value.audioChallenge && !audioReveal.value)
|
||||
|
||||
if (isClient) {
|
||||
watch(progress, value => localStorage.setItem('os_progress', JSON.stringify(value)), { deep: true })
|
||||
watch(xp, value => localStorage.setItem('os_xp', String(value)))
|
||||
type LearnStateResponse = LearnState
|
||||
|
||||
const persistGeneralCfg = () => {
|
||||
localStorage.setItem('os_cfg', JSON.stringify({ tts: cfg.value.tts, audioChallenge: cfg.value.audioChallenge }))
|
||||
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const dirtyState = reactive({ xp: false, progress: false, config: false })
|
||||
const savingState = ref(false)
|
||||
const pendingSave = ref(false)
|
||||
|
||||
async function loadLearnState() {
|
||||
if (!isClient) return
|
||||
|
||||
try {
|
||||
const response = await api.get<LearnStateResponse>('/api/learn/state')
|
||||
if (response) {
|
||||
xp.value = Number.isFinite(response.xp) ? Math.max(0, Math.round(response.xp)) : 0
|
||||
progress.value = (response.progress ?? {}) as LearnProgress
|
||||
cfg.value = { ...defaultCfg, ...(response.config || {}) }
|
||||
} else {
|
||||
xp.value = 0
|
||||
progress.value = {} as LearnProgress
|
||||
cfg.value = { ...defaultCfg }
|
||||
}
|
||||
dirtyState.xp = false
|
||||
dirtyState.progress = false
|
||||
dirtyState.config = false
|
||||
} catch (err) {
|
||||
console.error('Failed to load learn state', err)
|
||||
xp.value = 0
|
||||
progress.value = {} as LearnProgress
|
||||
cfg.value = { ...defaultCfg }
|
||||
} finally {
|
||||
audioReveal.value = !cfg.value.audioChallenge
|
||||
}
|
||||
|
||||
watch(() => cfg.value.tts, persistGeneralCfg)
|
||||
watch(() => cfg.value.audioChallenge, persistGeneralCfg)
|
||||
watch(() => cfg.value.radioLevel, value => localStorage.setItem('os_cfg_level', JSON.stringify({ v: value })))
|
||||
watch(() => cfg.value.voice, value => localStorage.setItem('os_cfg_voice', JSON.stringify({ v: value })))
|
||||
}
|
||||
|
||||
watch(() => cfg.value.audioChallenge, () => resetAudioReveal())
|
||||
function schedulePersist(immediate = false) {
|
||||
if (!isClient) return
|
||||
if (persistTimer) {
|
||||
clearTimeout(persistTimer)
|
||||
persistTimer = null
|
||||
}
|
||||
if (immediate) {
|
||||
void persistLearnState(true)
|
||||
return
|
||||
}
|
||||
persistTimer = setTimeout(() => {
|
||||
persistTimer = null
|
||||
void persistLearnState()
|
||||
}, 800)
|
||||
}
|
||||
|
||||
async function persistLearnState(force = false) {
|
||||
if (!isClient) return
|
||||
if (!force && !dirtyState.xp && !dirtyState.progress && !dirtyState.config) {
|
||||
return
|
||||
}
|
||||
if (savingState.value) {
|
||||
pendingSave.value = true
|
||||
return
|
||||
}
|
||||
savingState.value = true
|
||||
pendingSave.value = false
|
||||
|
||||
const payload = {
|
||||
xp: Math.max(0, Math.round(xp.value)),
|
||||
progress: JSON.parse(JSON.stringify(progress.value)),
|
||||
config: { ...cfg.value },
|
||||
}
|
||||
|
||||
try {
|
||||
await api.put('/api/learn/state', payload)
|
||||
dirtyState.xp = false
|
||||
dirtyState.progress = false
|
||||
dirtyState.config = false
|
||||
} catch (err) {
|
||||
console.error('Failed to persist learn state', err)
|
||||
} finally {
|
||||
savingState.value = false
|
||||
if (pendingSave.value) {
|
||||
pendingSave.value = false
|
||||
void persistLearnState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isClient) {
|
||||
await loadLearnState()
|
||||
}
|
||||
|
||||
const markConfigDirty = () => {
|
||||
dirtyState.config = true
|
||||
schedulePersist()
|
||||
}
|
||||
|
||||
if (isClient) {
|
||||
watch(progress, () => {
|
||||
dirtyState.progress = true
|
||||
schedulePersist()
|
||||
}, { deep: true })
|
||||
watch(xp, () => {
|
||||
dirtyState.xp = true
|
||||
schedulePersist()
|
||||
})
|
||||
watch(() => cfg.value.tts, markConfigDirty)
|
||||
watch(() => cfg.value.audioChallenge, () => {
|
||||
markConfigDirty()
|
||||
resetAudioReveal()
|
||||
})
|
||||
watch(() => cfg.value.radioLevel, markConfigDirty)
|
||||
watch(() => cfg.value.voice, markConfigDirty)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (persistTimer) {
|
||||
clearTimeout(persistTimer)
|
||||
persistTimer = null
|
||||
}
|
||||
if (dirtyState.xp || dirtyState.progress || dirtyState.config) {
|
||||
void persistLearnState(true)
|
||||
}
|
||||
})
|
||||
|
||||
const fieldMap = computed<Record<string, LessonField>>(() => {
|
||||
const map: Record<string, LessonField> = {}
|
||||
@@ -2138,8 +2203,14 @@ function toastNow(text: string) {
|
||||
|
||||
function resetAll() {
|
||||
if (!isClient) return
|
||||
localStorage.clear()
|
||||
location.reload()
|
||||
progress.value = {} as LearnProgress
|
||||
xp.value = 0
|
||||
cfg.value = { ...defaultCfg }
|
||||
audioReveal.value = !cfg.value.audioChallenge
|
||||
dirtyState.progress = true
|
||||
dirtyState.xp = true
|
||||
dirtyState.config = true
|
||||
schedulePersist(true)
|
||||
}
|
||||
|
||||
const worldTiltStyle = ref<any>({})
|
||||
@@ -2164,7 +2235,7 @@ async function requestSayAudio(cacheKey: string, payload: Record<string, unknown
|
||||
}
|
||||
|
||||
const request = (async () => {
|
||||
const response: any = await api.post('/api/atc/say', payload, { auth: false })
|
||||
const response: any = await api.post('/api/atc/say', payload)
|
||||
const audioData = response?.audio
|
||||
if (!audioData?.base64) {
|
||||
throw new Error('Missing audio data')
|
||||
|
||||
@@ -8,7 +8,7 @@ import {normalize, TTS_MODEL, normalizeATC} from "../../utils/normalize";
|
||||
import { getServerRuntimeConfig } from "../../utils/runtimeConfig";
|
||||
import {request} from "node:http";
|
||||
import { TransmissionLog } from "../../models/TransmissionLog";
|
||||
import { getUserFromEvent } from "../../utils/auth";
|
||||
import { requireUserSession } from "../../utils/auth";
|
||||
|
||||
|
||||
function outDir() {
|
||||
@@ -125,6 +125,8 @@ export default defineEventHandler(async (event) => {
|
||||
format?: AudioFmt | "smallest";
|
||||
}>(event);
|
||||
|
||||
const user = await requireUserSession(event);
|
||||
|
||||
const raw = (body?.text || "").trim();
|
||||
if (!raw) throw createError({ statusCode: 400, statusMessage: "text required" });
|
||||
|
||||
@@ -212,9 +214,8 @@ export default defineEventHandler(async (event) => {
|
||||
// await writeFile(fileJson, JSON.stringify(meta, null, 2), "utf-8");
|
||||
|
||||
try {
|
||||
const user = await getUserFromEvent(event)
|
||||
await TransmissionLog.create({
|
||||
user: user?._id,
|
||||
user: user._id,
|
||||
role: "atc",
|
||||
channel: "say",
|
||||
direction: "outgoing",
|
||||
|
||||
24
server/api/learn/state.get.ts
Normal file
24
server/api/learn/state.get.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { requireUserSession } from '../../utils/auth'
|
||||
import { LearnProfile } from '../../models/LearnProfile'
|
||||
import { createDefaultLearnConfig, createDefaultLearnState } from '~~/shared/learn/config'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUserSession(event)
|
||||
const profile = await LearnProfile.findOne({ user: user._id })
|
||||
|
||||
if (!profile) {
|
||||
return createDefaultLearnState()
|
||||
}
|
||||
|
||||
const config = { ...createDefaultLearnConfig(), ...(profile.config || {}) }
|
||||
const progress =
|
||||
profile.progress && typeof profile.progress === 'object'
|
||||
? JSON.parse(JSON.stringify(profile.progress))
|
||||
: {}
|
||||
|
||||
return {
|
||||
xp: typeof profile.xp === 'number' ? profile.xp : 0,
|
||||
progress,
|
||||
config,
|
||||
}
|
||||
})
|
||||
110
server/api/learn/state.put.ts
Normal file
110
server/api/learn/state.put.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { readBody } from 'h3'
|
||||
import { requireUserSession } from '../../utils/auth'
|
||||
import { LearnProfile } from '../../models/LearnProfile'
|
||||
import type { LearnConfig, LearnProgress, LessonProgress } from '~~/shared/learn/config'
|
||||
import { createDefaultLearnConfig } from '~~/shared/learn/config'
|
||||
|
||||
interface LearnStateUpdateBody {
|
||||
xp?: number
|
||||
progress?: LearnProgress
|
||||
config?: Partial<LearnConfig>
|
||||
}
|
||||
|
||||
function sanitizeProgress(input: LearnProgress | undefined): LearnProgress | undefined {
|
||||
if (!input || typeof input !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const sanitized: LearnProgress = {}
|
||||
|
||||
for (const [moduleId, lessons] of Object.entries(input)) {
|
||||
if (!lessons || typeof lessons !== 'object') {
|
||||
continue
|
||||
}
|
||||
|
||||
const lessonProgress: Record<string, LessonProgress> = {}
|
||||
|
||||
for (const [lessonId, value] of Object.entries(lessons as Record<string, any>)) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
continue
|
||||
}
|
||||
|
||||
const bestRaw = (value as any).best
|
||||
const doneRaw = (value as any).done
|
||||
|
||||
const best = typeof bestRaw === 'number' && Number.isFinite(bestRaw) ? Math.max(0, Math.min(100, Math.round(bestRaw))) : 0
|
||||
const done = Boolean(doneRaw)
|
||||
|
||||
lessonProgress[lessonId] = { best, done }
|
||||
}
|
||||
|
||||
sanitized[moduleId] = lessonProgress
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
function sanitizeConfig(input: Partial<LearnConfig> | undefined): LearnConfig | undefined {
|
||||
if (!input || typeof input !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const config = createDefaultLearnConfig()
|
||||
|
||||
if (typeof input.tts === 'boolean') {
|
||||
config.tts = input.tts
|
||||
}
|
||||
|
||||
if (typeof input.audioChallenge === 'boolean') {
|
||||
config.audioChallenge = input.audioChallenge
|
||||
}
|
||||
|
||||
if (typeof input.radioLevel === 'number' && Number.isFinite(input.radioLevel)) {
|
||||
const level = Math.round(input.radioLevel)
|
||||
config.radioLevel = Math.min(5, Math.max(1, level))
|
||||
}
|
||||
|
||||
if (typeof input.voice === 'string') {
|
||||
config.voice = input.voice.slice(0, 120)
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUserSession(event)
|
||||
const body = await readBody<LearnStateUpdateBody>(event)
|
||||
|
||||
let profile = await LearnProfile.findOne({ user: user._id })
|
||||
if (!profile) {
|
||||
profile = new LearnProfile({ user: user._id })
|
||||
}
|
||||
|
||||
if (typeof body.xp === 'number' && Number.isFinite(body.xp)) {
|
||||
profile.xp = Math.max(0, Math.round(body.xp))
|
||||
}
|
||||
|
||||
const progress = sanitizeProgress(body.progress)
|
||||
if (progress !== undefined) {
|
||||
profile.progress = progress
|
||||
}
|
||||
|
||||
const config = sanitizeConfig(body.config)
|
||||
if (config !== undefined) {
|
||||
profile.config = config
|
||||
}
|
||||
|
||||
await profile.save()
|
||||
|
||||
const responseConfig = { ...createDefaultLearnConfig(), ...(profile.config || {}) }
|
||||
const responseProgress =
|
||||
profile.progress && typeof profile.progress === 'object'
|
||||
? JSON.parse(JSON.stringify(profile.progress))
|
||||
: {}
|
||||
|
||||
return {
|
||||
xp: typeof profile.xp === 'number' ? profile.xp : 0,
|
||||
progress: responseProgress,
|
||||
config: responseConfig,
|
||||
}
|
||||
})
|
||||
34
server/models/LearnProfile.ts
Normal file
34
server/models/LearnProfile.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import mongoose from 'mongoose'
|
||||
import type { LearnConfig, LearnProgress } from '~~/shared/learn/config'
|
||||
|
||||
export interface LearnProfileDocument extends mongoose.Document {
|
||||
user: mongoose.Types.ObjectId
|
||||
xp: number
|
||||
progress: LearnProgress
|
||||
config: LearnConfig
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
const learnProfileSchema = new mongoose.Schema<LearnProfileDocument>(
|
||||
{
|
||||
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true, unique: true },
|
||||
xp: { type: Number, default: 0 },
|
||||
progress: { type: mongoose.Schema.Types.Mixed, default: () => ({}) },
|
||||
config: {
|
||||
tts: { type: Boolean, default: false },
|
||||
radioLevel: { type: Number, default: 4, min: 1, max: 5 },
|
||||
voice: { type: String, default: '', trim: true },
|
||||
audioChallenge: { type: Boolean, default: false },
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
},
|
||||
)
|
||||
|
||||
learnProfileSchema.index({ user: 1 }, { unique: true })
|
||||
|
||||
export const LearnProfile =
|
||||
(mongoose.models.LearnProfile as mongoose.Model<LearnProfileDocument> | undefined) ||
|
||||
mongoose.model<LearnProfileDocument>('LearnProfile', learnProfileSchema)
|
||||
38
shared/learn/config.ts
Normal file
38
shared/learn/config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export interface LessonProgress {
|
||||
best: number
|
||||
done: boolean
|
||||
}
|
||||
|
||||
export type LearnProgress = Record<string, Record<string, LessonProgress>>
|
||||
|
||||
export interface LearnConfig {
|
||||
tts: boolean
|
||||
radioLevel: number
|
||||
voice: string
|
||||
audioChallenge: boolean
|
||||
}
|
||||
|
||||
export interface LearnState {
|
||||
xp: number
|
||||
progress: LearnProgress
|
||||
config: LearnConfig
|
||||
}
|
||||
|
||||
export const LEARN_CONFIG_DEFAULTS: LearnConfig = {
|
||||
tts: false,
|
||||
radioLevel: 4,
|
||||
voice: '',
|
||||
audioChallenge: false,
|
||||
}
|
||||
|
||||
export function createDefaultLearnConfig(): LearnConfig {
|
||||
return { ...LEARN_CONFIG_DEFAULTS }
|
||||
}
|
||||
|
||||
export function createDefaultLearnState(): LearnState {
|
||||
return {
|
||||
xp: 0,
|
||||
progress: {} as LearnProgress,
|
||||
config: createDefaultLearnConfig(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user