[+] Location state

This commit is contained in:
2025-11-20 19:06:43 +08:00
parent afa9b4ec02
commit c3982bc268
6 changed files with 194 additions and 99 deletions
+2 -1
View File
@@ -5,7 +5,8 @@ import { isKanji } from 'wanakana'
// Please put OPENAI_API_KEY in your environment variables.
const client = new OpenAI()
const req = {
model: "gpt-5.1-chat-latest",
// model: "gpt-5.1-chat-latest",
model: "gpt-4.1",
messages: [
{
role: "system",
+21 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import type { PageProps } from "./$types"
import { goto } from "$app/navigation";
import AppBar from "../../../components/appbar/AppBar.svelte";
import Button from "../../../components/Button.svelte";
import SongInfo from "../../../components/listitem/SongInfo.svelte";
@@ -20,6 +21,25 @@
await API.saveUserData(data.user.data)
isFavorite = !isFavorite
}
async function startPractice() {
if (songs.length === 0) return;
const firstIndex = 0;
const firstSong = songs[firstIndex];
data.user.data.loc = {
currentPlaylistId: meta.id,
currentSongIndex: firstIndex,
playedSongIds: [firstSong.id],
playMode: 'sequential',
isFinished: false,
lastResultId: null
};
await API.saveUserData({ loc: data.user.data.loc });
goto(`/song/${firstSong.id}`);
}
</script>
<AppBar title="歌单详情" right={[
@@ -39,7 +59,7 @@
<div class="m3-font-body-small text-surface-variant">歌曲数: {meta.trackCount}</div>
</div>
<div>
<Button>开始练习</Button>
<Button onclick={startPractice}>开始练习</Button>
</div>
</div>
</div>
+5 -3
View File
@@ -1,9 +1,10 @@
import { error } from '@sveltejs/kit'
import { getResult } from '$lib/server/result'
import type { PageServerLoad } from './$types'
import { getLyricsProcessed, getSongRaw } from "$lib/server/songs.ts";
import { getLyricsProcessed, getSongRaw, getPlaylist } from "$lib/server/songs.ts";
export const load: PageServerLoad = async ({ params }) => {
export const load: PageServerLoad = async ({ params, parent }) => {
const { user } = await parent()
const result = await getResult(params.id)
if (!result) throw error(404, 'Result not found')
@@ -12,6 +13,7 @@ export const load: PageServerLoad = async ({ params }) => {
return {
result: structuredClone(result),
lrc: await getLyricsProcessed(result.songId),
song
song,
playlist: await user.data?.loc?.currentPlaylistId?.let(getPlaylist)
}
}
+86 -31
View File
@@ -1,39 +1,34 @@
<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";
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"
import Chart from "chart.js/auto";
import Chart from "chart.js/auto"
import { API } from "$lib/client"
let { data }: PageProps = $props();
let { result, lrc } = data;
import type { NeteaseSong, UserData } from "../../../shared/types"
let { data }: PageProps = $props()
// Destructure result for easier access
let { totalTyped, startTime, endTime, totalRight, statsHistory, songId } = result;
let { totalTyped, startTime, endTime, totalRight, statsHistory, songId } = data.result
// Calculate duration for display
let duration = endTime - startTime;
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: 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 },
];
{ label: "用时", value: new Date(duration).toISOString().slice(14, 19) },
{ label: "歌曲时长", value: new Date(data.song.dt).toISOString().slice(14, 19) }
]
let chartCanvas: HTMLCanvasElement;
let chart: Chart;
let chartCanvas: HTMLCanvasElement
let chart: Chart
$effect(() => {
if (chartCanvas && statsHistory.length > 0) {
@@ -44,7 +39,7 @@
datasets: [
{
label: "速度 (CPM)",
data: statsHistory.map((h: any) => h.cpm),
data: statsHistory.map((h: { cpm: number }) => h.cpm),
tension: 0.4,
pointRadius: 0,
fill: true,
@@ -53,7 +48,7 @@
},
{
label: "准确率 (%)",
data: statsHistory.map((h: any) => h.acc),
data: statsHistory.map((h: { acc: number }) => h.acc),
tension: 0.4,
yAxisID: "y1",
pointRadius: 0,
@@ -88,13 +83,73 @@
},
},
},
});
})
}
return () => {
if (chart) chart.destroy();
};
});
if (chart) chart.destroy()
}
})
// Playlist Navigation Logic
let nextSongId = $state<number | null>(null)
let isPlaylistFinished = $state(false)
const loc = data.user.data.loc
const playlist = data.playlist
// Check if this is the latest result for the current playlist session
const isCurrentResult = loc?.lastResultId === data.result._id
// Compute next state immediately
if (playlist && loc && isCurrentResult) {
if (loc.playMode === 'random') {
const unplayed = playlist.tracks.filter((t: NeteaseSong) => !loc.playedSongIds.includes(t.id))
if (unplayed.length > 0) {
const nextSong = unplayed[Math.floor(Math.random() * unplayed.length)]
nextSongId = nextSong.id
} else isPlaylistFinished = true
} else {
const nextIndex = loc.currentSongIndex + 1
if (nextIndex < playlist.tracks.length) {
nextSongId = playlist.tracks[nextIndex].id
} else isPlaylistFinished = true
}
}
async function handleNext() {
if (nextSongId !== null) {
if (!data.user.data.loc || !data.playlist) return
const nextIndex = data.playlist.tracks.findIndex((t: NeteaseSong) => t.id === nextSongId)
const newLoc = {
...data.user.data.loc,
currentSongIndex: nextIndex,
isFinished: false
}
if (!newLoc.playedSongIds.includes(nextSongId)) {
newLoc.playedSongIds = [...newLoc.playedSongIds, nextSongId]
}
data.user.data.loc = newLoc
await API.saveUserData({ loc: newLoc })
goto(`/song/${nextSongId}`, { replaceState: true })
} else if (isPlaylistFinished) {
// Clear playlist state
await API.saveUserData({ loc: null as any })
goto(`/playlist/${data.playlist!.id}`)
} else {
// Restart
goto(`/song/${songId}`, { replaceState: true })
}
}
let buttonText = $derived(
nextSongId !== null ? "下一首" :
isPlaylistFinished ? "返回歌单" :
"再来一次"
)
</script>
<AppBar title={data.song.name} sub={artistAndAlbum(data.song)} />
@@ -125,6 +180,6 @@
<div class="flex-1"></div>
<div class="hbox justify-end pt-8px pb-16px w-full">
<Button w-full onclick={() => goto(`/song/${songId}`)}>下一首</Button>
<Button big w-full onclick={handleNext}>{buttonText}</Button>
</div>
</div>
+9 -2
View File
@@ -148,10 +148,17 @@
const res = await API.saveResult({
songId: data.song.id,
endTime: Date.now(),
realTimeFactor: (Date.now() - startTime) / (data.song.dt / 1000),
realTimeFactor: data.song.dt / (Date.now() - startTime),
totalTyped, totalRight, startTime, statsHistory
})
goto(`/results/${res.id}`)
if (data.user.data.loc?.currentPlaylistId) {
data.user.data.loc.isFinished = true;
data.user.data.loc.lastResultId = res.id;
await API.saveUserData({ loc: data.user.data.loc });
}
goto(`/results/${res.id}`, { replaceState: true })
}
</script>
+71 -61
View File
@@ -1,96 +1,106 @@
export type LyricSegment = string | string[]
export type LyricSegment = string | string[];
export interface LyricLine {
time: string
lyric: LyricSegment[]
time: string;
lyric: LyricSegment[];
}
export interface Song {
title: string
lyrics: LyricLine[]
title: string;
lyrics: LyricLine[];
}
export interface NeteaseSong {
id: number
name: string
al: {
id: number
name: string
picUrl: string
}
ar: { id: number, name: string }[]
dt: number
export interface NeteaseSong {
id: number;
name: string;
al: {
id: number;
name: string;
picUrl: string;
};
ar: { id: number; name: string }[];
dt: number;
}
export interface NeteasePlaylist {
id: number
name: string
coverImgUrl: string
creator: {
nickname: string
}
tracks: NeteaseSong[]
id: number;
name: string;
coverImgUrl: string;
creator: {
nickname: string;
};
tracks: NeteaseSong[];
}
export interface ProcessedLyricLine {
jp: LyricSegment[]
kanji: string
hiragana: string
cleanHiragana: string
romaji: string
cleanRomaji: string
jp: LyricSegment[];
kanji: string;
hiragana: string;
cleanHiragana: string;
romaji: string;
cleanRomaji: string;
}
export interface DisplayCharacter {
char: string
state: 'correct' | 'incorrect' | 'untyped' | 'ignored'
originalSegment?: LyricSegment
segmentHiragana?: string
char: string;
state: "correct" | "incorrect" | "untyped" | "ignored";
originalSegment?: LyricSegment;
segmentHiragana?: string;
}
export interface GameStats {
wpm: number
accuracy: number
totalTyped: number
totalCorrect: number
startTime: number
totalTime: number
wpm: number;
accuracy: number;
totalTyped: number;
totalCorrect: number;
startTime: number;
totalTime: number;
}
export interface UserDocument {
_id?: any
registUA: string
createdAt: Date
sessions: string[]
syncCode?: string
syncCodeCreated?: Date
_id?: any;
registUA: string;
createdAt: Date;
sessions: string[];
syncCode?: string;
syncCodeCreated?: Date;
// User data
data: UserData
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
_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,
showRomaji: true,
showRomajiOnError: true
}
showRomajiOnError: true,
};
export interface UserData {
myPlaylists?: number[]
playHistory?: GameStats[]
typingSettings?: typeof typingSettingsDefault
}
myPlaylists?: number[];
playHistory?: GameStats[];
typingSettings?: typeof typingSettingsDefault;
// Playlist state
loc?: {
currentPlaylistId: number;
currentSongIndex: number;
playedSongIds: number[];
playMode: "random" | "sequential";
isFinished: boolean;
lastResultId: string | null;
};
}