From 6f18e59fbe5e893caf563d23aa58566de1570a75 Mon Sep 17 00:00:00 2001 From: Azalea <22280294+hykilpikonna@users.noreply.github.com> Date: Wed, 19 Nov 2025 18:00:38 +0800 Subject: [PATCH] [+] Save results --- src/lib/client.ts | 18 ++++++ src/lib/server/result.ts | 18 ++++++ src/lib/user.ts | 15 ----- src/routes/api/result/+server.ts | 19 ++++++ src/routes/results/[id]/+page.server.ts | 17 ++++++ src/routes/results/[id]/+page.svelte | 78 +++++++++++++++++++++++++ src/routes/song/[slug]/+page.svelte | 62 ++++++++++++-------- src/routes/song/[slug]/IMEHelper.ts | 13 +++++ src/shared/tools.ts | 3 + src/shared/types.ts | 13 +++++ 10 files changed, 216 insertions(+), 40 deletions(-) create mode 100644 src/lib/client.ts create mode 100644 src/lib/server/result.ts delete mode 100644 src/lib/user.ts create mode 100644 src/routes/api/result/+server.ts create mode 100644 src/routes/results/[id]/+page.server.ts create mode 100644 src/routes/results/[id]/+page.svelte create mode 100644 src/shared/tools.ts diff --git a/src/lib/client.ts b/src/lib/client.ts new file mode 100644 index 0000000..fec5b66 --- /dev/null +++ b/src/lib/client.ts @@ -0,0 +1,18 @@ +import type { ResultDocument, UserData } from "../shared/types"; + + +export async function post(endpoint: string, data: any) { + return await fetch(endpoint, { + method: 'POST', + body: JSON.stringify(data), + headers: { + 'Content-Type': 'application/json' + } + }).then(res => res.json()) +} + +export const API = { + post, + saveUserData: async (data: Partial) => await post('/api/user', data), + saveResult: async (data: Omit) => await post('/api/result', data) +} diff --git a/src/lib/server/result.ts b/src/lib/server/result.ts new file mode 100644 index 0000000..531f02a --- /dev/null +++ b/src/lib/server/result.ts @@ -0,0 +1,18 @@ +import { db } from "./db" +import { ObjectId } from "mongodb" + +const results = db.collection("results") + +export async function saveResult(data: Omit): Promise { + const doc = { ...data, createdAt: new Date() } + const res = await results.insertOne(doc) + return res.insertedId.toString() +} + +export async function getResult(id: string): Promise { + try { + return await results.findOne({ _id: new ObjectId(id) }) + } catch { + return null + } +} diff --git a/src/lib/user.ts b/src/lib/user.ts deleted file mode 100644 index 24ce017..0000000 --- a/src/lib/user.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { UserData } from "../shared/types"; - -/** - * Save user data to the server. - * @param data Partial user data to update - */ -export async function saveUserData(data: Partial) { - await fetch('/api/user', { - method: 'POST', - body: JSON.stringify(data), - headers: { - 'Content-Type': 'application/json' - } - }) -} diff --git a/src/routes/api/result/+server.ts b/src/routes/api/result/+server.ts new file mode 100644 index 0000000..d8b4c55 --- /dev/null +++ b/src/routes/api/result/+server.ts @@ -0,0 +1,19 @@ +import { error, json } from '@sveltejs/kit' +import { saveResult } from '$lib/server/result' +import { getUserBySession, login } from '$lib/server/user' +import type { RequestHandler } from './$types' + +export const POST: RequestHandler = async ({ request, cookies }) => { + try { + const data = await request.json() + const user = await login(cookies.get('session')) + data.userId = user._id + + // Validate data here if needed + const id = await saveResult(data) + return json({ id }) + } catch (e) { + console.error('Failed to save result', e) + throw error(500, 'Internal Server Error') + } +} diff --git a/src/routes/results/[id]/+page.server.ts b/src/routes/results/[id]/+page.server.ts new file mode 100644 index 0000000..c293ef5 --- /dev/null +++ b/src/routes/results/[id]/+page.server.ts @@ -0,0 +1,17 @@ +import { error } from '@sveltejs/kit' +import { getResult } from '$lib/server/result' +import type { PageServerLoad } from './$types' +import { getLyricsProcessed, getSongMeta, parseBrief } from "$lib/server/songs.ts"; + +export const load: PageServerLoad = async ({ params }) => { + const result = await getResult(params.id) + if (!result) throw error(404, 'Result not found') + + const song = await getSongMeta(result.songId) + + return { + result: structuredClone(result), + lrc: await getLyricsProcessed(result.songId), + song, brief: parseBrief(song) + } +} diff --git a/src/routes/results/[id]/+page.svelte b/src/routes/results/[id]/+page.svelte new file mode 100644 index 0000000..a3b0612 --- /dev/null +++ b/src/routes/results/[id]/+page.svelte @@ -0,0 +1,78 @@ + + + + +
+
+
练习结果
+
+ +
+ {#each fields as field} +
+
{field.label}
+
{field.value}
+
+ {/each} +
+ + +
+ + + {#each [0, 25, 50, 75, 100] as y} + + {/each} + + + {#if statsHistory.length > 1} + `${(i / (statsHistory.length - 1)) * 100},${100 - (h.cpm / 300) * 100}`).join(' ')} + fill="none" + stroke="#7b78c2" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + /> + + + `${(i / (statsHistory.length - 1)) * 100},${100 - (h.acc / 100) * 100}`).join(' ')} + fill="none" + stroke="#e5a657" + stroke-width="2" + stroke-dasharray="4" + stroke-opacity="0.5" + /> + {/if} + +
+ +
+ +
+ +
+
diff --git a/src/routes/song/[slug]/+page.svelte b/src/routes/song/[slug]/+page.svelte index fc8563d..bfc9411 100644 --- a/src/routes/song/[slug]/+page.svelte +++ b/src/routes/song/[slug]/+page.svelte @@ -5,11 +5,13 @@ import { onMount, tick } from "svelte"; import { typingSettingsDefault, type LyricSegment } from "../../../shared/types.ts"; import { isKana, isKanji, toHiragana, toKatakana, toRomaji } from "wanakana"; - import { composeList, fuzzyEquals } from "./IMEHelper.ts"; + import { composeList, fuzzyEquals, processLrcLine, type ProcLrcLine, type ProcLrcSeg } from "./IMEHelper.ts"; import MenuItem from "../../../components/material3/MenuItem.svelte"; import "../../../shared/ext.ts" - import { saveUserData } from "$lib/user"; + import { API } from "$lib/client.ts"; import { animateCaret } from "./animation.ts"; + import { goto } from '$app/navigation'; + import { artistAndAlbum } from "../../../shared/tools.ts"; let { data }: PageProps = $props() @@ -22,23 +24,12 @@ // Settings stored in user data let settings = $state(data.user.data?.typingSettings ?? typingSettingsDefault) - $effect(() => { saveUserData({ typingSettings: settings }) }) + $effect(() => { API.saveUserData({ typingSettings: settings }) }) const _preprocessKana = (kana: string) => settings.allKata ? toKatakana(kana) : kana const preprocessKana = (kana: string, state?: string) => (settings.showRomaji || (settings.showRomajiOnError && state === 'wrong')) ? `${_preprocessKana(kana)}${toRomaji(kana)}` : _preprocessKana(kana) // Process each line into segments with swi (start word index) and kanji/kana - type ProcLrcSeg = { swi: number, kanji?: string, kana: string } - type ProcLrcLine = { parts: ProcLrcSeg[], totalLen: number } - function processLrcLine(line: LyricSegment[]): ProcLrcLine { - let result: any[] = line.map(part => (typeof part === "string" ? { kana: part } : { kanji: part[0], kana: part[1] })) - let swi = 0 - for (let item of result) { - item['swi'] = swi - swi += item.kana.length - } - return { parts: result, totalLen: swi } - } - let processedLrc: ProcLrcLine[] = data.lrc.map(line => processLrcLine(line.lyric)) + let processedLrc: ProcLrcLine[] = data.lrc.map(line => processLrcLine(line.lyric)).slice(0, 2) // State tracking for each kana character: UNSEEN, RIGHT, WRONG let states = $state(processedLrc.map(line => new Array(line.totalLen).fill('unseen'))) @@ -51,8 +42,15 @@ } // For computing stats - let startTime = $state(null) + let startTime = $state(0) let now = $state(Date.now()) + let statsHistory = $state<{ t: number, cpm: number, acc: number }[]>([]) + + // Computed stats + let flat = $derived(states.flat()) + let progress = $derived(Math.min(100, Math.floor((flat.filter(s => s !== 'unseen').length / flat.length) * 100))) + let totalTyped = $derived(flat.filter(s => s !== 'unseen').length) + let totalRight = $derived(flat.filter(s => s === 'right' || s === 'fuzzy').length) onMount(() => { // Auto focus & refocus @@ -87,9 +85,19 @@ if (res === 'wrong' && !imeUsed && !isComposed && composeList.includes(exp)) return // Need to compose, stop here states[li][wi] = res + // Record stats + const elapsed = (Date.now() - startTime) / 60000 + const cpm = totalTyped / elapsed + const acc = (totalRight / totalTyped) * 100 + statsHistory.push({ t: Date.now(), cpm, acc }) + // Move index wi += 1 - if (wi >= cLine.totalLen) { li += 1; wi = 0 } + if (wi >= cLine.totalLen) { + li += 1; + wi = 0 + if (li >= processedLrc.length) submitResult() + } inp = inp.slice(1) } @@ -105,15 +113,20 @@ // Caret: Typing indicator let caret: HTMLDivElement $effect(() => { li; wi; animateCaret(caret) }) - - // Computed stats - let flat = $derived(states.flat()) - let progress = $derived(Math.min(100, Math.floor((flat.filter(s => s !== 'unseen').length / flat.length) * 100))) - let totalTyped = $derived(flat.filter(s => s !== 'unseen').length) - let totalRight = $derived(flat.filter(s => s === 'right' || s === 'fuzzy').length) + + // Result is stored on the server and is fetched from a separate results page + async function submitResult() { + const res = await API.saveResult({ + songId: data.raw.id, + endTime: Date.now(), + realTimeFactor: (Date.now() - startTime) / (data.raw.dt / 1000), + totalTyped, totalRight, startTime, statsHistory + }) + goto(`/results/${res.id}`) + } - a.name).join(", ") + " - " + data.brief.album}> + settings.isFuri = !settings.isFuri}>{settings.isFuri ? "隐藏" : "显示"}假名标注 settings.allKata = !settings.allKata}>{settings.allKata ? "恢复平假名" : "全部转换为片假名"} settings.showRomaji = !settings.showRomaji}>{settings.showRomaji ? "隐藏罗马音" : "显示罗马音"} @@ -132,7 +145,6 @@
正確率: {totalTyped === 0 ? 100 : Math.round((totalRight / totalTyped) * 100)}%
-
正确:{flat.filter(s => s === 'right').length}
模糊:{flat.filter(s => s === 'fuzzy').length}
错误:{flat.filter(s => s === 'wrong').length}
diff --git a/src/routes/song/[slug]/IMEHelper.ts b/src/routes/song/[slug]/IMEHelper.ts index 7ea0a6a..1db9f80 100644 --- a/src/routes/song/[slug]/IMEHelper.ts +++ b/src/routes/song/[slug]/IMEHelper.ts @@ -1,4 +1,17 @@ import { toHiragana } from "wanakana"; +import type { LyricSegment } from "../../../shared/types"; + +export type ProcLrcSeg = { swi: number, kanji?: string, kana: string } +export type ProcLrcLine = { parts: ProcLrcSeg[], totalLen: number } +export function processLrcLine(line: LyricSegment[]): ProcLrcLine { + let result: any[] = line.map(part => (typeof part === "string" ? { kana: part } : { kanji: part[0], kana: part[1] })) + let swi = 0 + for (let item of result) { + item['swi'] = swi + swi += item.kana.length + } + return { parts: result, totalLen: swi } +} // Fuzzy matching rules const fuzzyMatch = [['わ', 'は'], ['を', 'お']] diff --git a/src/shared/tools.ts b/src/shared/tools.ts new file mode 100644 index 0000000..35957ad --- /dev/null +++ b/src/shared/tools.ts @@ -0,0 +1,3 @@ +import type { NeteaseSongBrief } from "./types"; + +export const artistAndAlbum = (song: NeteaseSongBrief) => `${song.artists.map(it => it.name).join(', ')} - ${song.album}` \ No newline at end of file diff --git a/src/shared/types.ts b/src/shared/types.ts index f7ee227..ec4cadd 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -56,6 +56,19 @@ export interface UserDocument { data?: UserData } +export interface ResultDocument { + _id?: any + userId?: any // Optional, if we want to link to user + songId: number + totalTyped: number + totalRight: number + startTime: number + endTime: number + realTimeFactor: number + statsHistory: { t: number, cpm: number, acc: number }[] + createdAt: Date +} + export const typingSettingsDefault = { isFuri: true, allKata: false,