[+] Save results
This commit is contained in:
@@ -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<UserData>) => await post('/api/user', data),
|
||||
saveResult: async (data: Omit<ResultDocument, "_id" | "createdAt">) => await post('/api/result', data)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { db } from "./db"
|
||||
import { ObjectId } from "mongodb"
|
||||
|
||||
const results = db.collection<ResultDocument>("results")
|
||||
|
||||
export async function saveResult(data: Omit<ResultDocument, "_id" | "createdAt">): Promise<string> {
|
||||
const doc = { ...data, createdAt: new Date() }
|
||||
const res = await results.insertOne(doc)
|
||||
return res.insertedId.toString()
|
||||
}
|
||||
|
||||
export async function getResult(id: string): Promise<ResultDocument | null> {
|
||||
try {
|
||||
return await results.findOne({ _id: new ObjectId(id) })
|
||||
} catch {
|
||||
return 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<UserData>) {
|
||||
await fetch('/api/user', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
import { goto } from '$app/navigation';
|
||||
import AppBar from '../../../components/appbar/AppBar.svelte';
|
||||
import { artistAndAlbum } from '../../../shared/tools';
|
||||
import Button from '../../../components/Button.svelte';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let { result, lrc } = data;
|
||||
|
||||
// Destructure result for easier access
|
||||
let { totalTyped, startTime, endTime, totalRight, statsHistory, songId } = result;
|
||||
|
||||
// Calculate duration for display
|
||||
let duration = endTime - startTime;
|
||||
|
||||
let fields = [
|
||||
{ label: '速度', value: Math.round(totalTyped / (Math.max(1, duration) / 60000)) },
|
||||
{ label: '准确率', value: totalTyped === 0 ? 100 : (Math.round((totalRight / totalTyped) * 10000) / 100) },
|
||||
{ label: '实时率', value: data.result.realTimeFactor.toFixed(2) + 'x' },
|
||||
{ label: '字数', value: totalTyped }
|
||||
]
|
||||
</script>
|
||||
|
||||
<AppBar title={data.brief.name} sub={artistAndAlbum(data.brief)}/>
|
||||
|
||||
<div class="vbox gap-16px p-content">
|
||||
<div class="hbox gap-12px items-end! h-48px">
|
||||
<div class="m3-font-headline-small">练习结果</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-16px">
|
||||
{#each fields as field}
|
||||
<div class="vbox flex-1">
|
||||
<div class="m3-font-title-medium mfg-on-surface-variant">{field.label}</div>
|
||||
<div class="m3-font-headline-large font-medium mfg-on-surface">{field.value}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Chart -->
|
||||
<div class="h-120px w-full bg-surface-container rounded-12px relative overflow-hidden">
|
||||
<svg class="w-full h-full" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||
<!-- Grid lines -->
|
||||
{#each [0, 25, 50, 75, 100] as y}
|
||||
<line x1="0" y1={y} x2="100" y2={y} stroke="rgba(0,0,0,0.05)" stroke-width="1" />
|
||||
{/each}
|
||||
|
||||
<!-- Speed Line -->
|
||||
{#if statsHistory.length > 1}
|
||||
<polyline
|
||||
points={statsHistory.map((h: any, i: number) => `${(i / (statsHistory.length - 1)) * 100},${100 - (h.cpm / 300) * 100}`).join(' ')}
|
||||
fill="none"
|
||||
stroke="#7b78c2"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
|
||||
<!-- Accuracy Line (dashed) -->
|
||||
<polyline
|
||||
points={statsHistory.map((h: any, i: number) => `${(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}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<div class="hbox justify-end pt-8px">
|
||||
<Button onclick={() => goto(`/song/${songId}`)}>下一首</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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')) ? `<ruby>${_preprocessKana(kana)}<rt>${toRomaji(kana)}</rt></ruby>` : _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<number | null>(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}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<AppBar title={data.brief.name} sub={data.brief.artists.map(a => a.name).join(", ") + " - " + data.brief.album}>
|
||||
<AppBar title={data.brief.name} sub={artistAndAlbum(data.brief)}>
|
||||
<MenuItem textIcon="あ" onclick={() => settings.isFuri = !settings.isFuri}>{settings.isFuri ? "隐藏" : "显示"}假名标注</MenuItem>
|
||||
<MenuItem textIcon="カ" onclick={() => settings.allKata = !settings.allKata}>{settings.allKata ? "恢复平假名" : "全部转换为片假名"}</MenuItem>
|
||||
<MenuItem icon="i-material-symbols:language-japanese-kana-rounded" onclick={() => settings.showRomaji = !settings.showRomaji}>{settings.showRomaji ? "隐藏罗马音" : "显示罗马音"}</MenuItem>
|
||||
@@ -132,7 +145,6 @@
|
||||
<div>正確率: {totalTyped === 0 ? 100 : Math.round((totalRight / totalTyped) * 100)}%</div>
|
||||
</div>
|
||||
<div class="hbox justify-between">
|
||||
<!-- <div>进度: {progress}%</div> -->
|
||||
<div>正确:{flat.filter(s => s === 'right').length}</div>
|
||||
<div>模糊:{flat.filter(s => s === 'fuzzy').length}</div>
|
||||
<div>错误:{flat.filter(s => s === 'wrong').length}</div>
|
||||
|
||||
@@ -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 = [['わ', 'は'], ['を', 'お']]
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { NeteaseSongBrief } from "./types";
|
||||
|
||||
export const artistAndAlbum = (song: NeteaseSongBrief) => `${song.artists.map(it => it.name).join(', ')} - ${song.album}`
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user