Initial commit of Vox English challenge system
This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
build
|
||||
.svelte-kit
|
||||
.git
|
||||
.env
|
||||
data.db
|
||||
data.db-journal
|
||||
*.log
|
||||
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# Database
|
||||
data.db
|
||||
data.db-journal
|
||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["svelte.svelte-vscode"]
|
||||
}
|
||||
40
Dockerfile
Normal file
40
Dockerfile
Normal file
@@ -0,0 +1,40 @@
|
||||
# Build stage
|
||||
FROM node:20-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies for better-sqlite3
|
||||
RUN apt-get update && apt-get install -y python3 make g++
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install production dependencies only
|
||||
COPY package*.json ./
|
||||
RUN apt-get update && apt-get install -y python3 make g++ \
|
||||
&& npm install --omit=dev \
|
||||
&& apt-get purge -y python3 make g++ \
|
||||
&& apt-get autoremove -y \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /app/build ./build
|
||||
COPY --from=builder /app/static ./static
|
||||
|
||||
# Ensure the database file isn't baked in unless desired,
|
||||
# but adapter-node needs the build folder.
|
||||
# The app will create data.db in /app/ at runtime.
|
||||
|
||||
ENV PORT=1995
|
||||
ENV NODE_ENV=production
|
||||
|
||||
EXPOSE 1995
|
||||
|
||||
CMD ["node", "build"]
|
||||
26
README.md
Normal file
26
README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Vox (词脉)
|
||||
|
||||
智能单词挑战系统。
|
||||
|
||||
- **极速**:Svelte 5 + Tailwind 4
|
||||
- **智能**:错题本 + 专项练习
|
||||
- **持久**:SQLite 存储
|
||||
|
||||
```bash
|
||||
npm i && npm run dev # Port: 1995
|
||||
```
|
||||
|
||||
## Docker
|
||||
### 1. 使用 Docker CLI
|
||||
```bash
|
||||
docker build -t vox-app .
|
||||
docker run -p 1995:1995 -v $(pwd)/data.db:/app/data.db vox-app
|
||||
```
|
||||
|
||||
### 2. 使用 Docker Compose (推荐)
|
||||
```bash
|
||||
# 以后台模式运行
|
||||
docker-compose up -d
|
||||
# 查看日志
|
||||
docker-compose logs -f
|
||||
```
|
||||
12
docker-compose.yml
Normal file
12
docker-compose.yml
Normal file
@@ -0,0 +1,12 @@
|
||||
services:
|
||||
vox:
|
||||
build: .
|
||||
container_name: vox-app
|
||||
ports:
|
||||
- "1995:1995"
|
||||
volumes:
|
||||
- ./data.db:/app/data.db
|
||||
restart: always
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=1995
|
||||
25
generate_template.cjs
Normal file
25
generate_template.cjs
Normal file
@@ -0,0 +1,25 @@
|
||||
const XLSX = require('xlsx');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const data = [
|
||||
['word', 'meaning'],
|
||||
['apple', 'n. 苹果'],
|
||||
['banana', 'n. 香蕉'],
|
||||
['persistent', 'adj. 执着的,持久的'],
|
||||
['eloquent', 'adj. 雄辩的,有口才的']
|
||||
];
|
||||
|
||||
const ws = XLSX.utils.aoa_to_sheet(data);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Words');
|
||||
|
||||
const staticDir = path.join(__dirname, 'static');
|
||||
if (!fs.existsSync(staticDir)) {
|
||||
fs.mkdirSync(staticDir);
|
||||
}
|
||||
|
||||
const outFile = path.join(staticDir, 'import_template.xlsx');
|
||||
XLSX.writeFile(wb, outFile);
|
||||
|
||||
console.log('Template generated at:', outFile);
|
||||
3341
package-lock.json
generated
Normal file
3341
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
Normal file
36
package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "eng",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@sveltejs/kit": "^2.50.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@types/node": "^25.5.0",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-svelte": "^0.577.0",
|
||||
"postcss": "^8.5.8",
|
||||
"svelte": "^5.51.0",
|
||||
"svelte-check": "^4.4.2",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"xlsx": "^0.18.5"
|
||||
}
|
||||
}
|
||||
6
postcss.config.cjs
Normal file
6
postcss.config.cjs
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
17
src/app.css
Normal file
17
src/app.css
Normal file
@@ -0,0 +1,17 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--primary: #6366f1;
|
||||
--primary-hover: #4f46e5;
|
||||
--secondary: #f97316;
|
||||
--accent: #8b5cf6;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-slate-100 text-slate-800 antialiased;
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.glass {
|
||||
@apply bg-white/20 backdrop-blur-sm border border-white/30;
|
||||
}
|
||||
13
src/app.d.ts
vendored
Normal file
13
src/app.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Locals {
|
||||
user: {
|
||||
userId: number;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
} | null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
11
src/app.html
Normal file
11
src/app.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
20
src/hooks.server.ts
Normal file
20
src/hooks.server.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { redirect, type Handle } from '@sveltejs/kit';
|
||||
import { getSession } from '$lib/server/auth';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const session = getSession(event.cookies);
|
||||
event.locals.user = session;
|
||||
|
||||
const path = event.url.pathname;
|
||||
|
||||
// Public routes that don't need auth
|
||||
const publicRoutes = ['/login', '/register', '/api/auth'];
|
||||
const isPublicRoute = publicRoutes.some(route => path.startsWith(route));
|
||||
|
||||
if (!event.locals.user && !isPublicRoute && path !== '/') {
|
||||
throw redirect(303, '/login');
|
||||
}
|
||||
|
||||
const response = await resolve(event);
|
||||
return response;
|
||||
};
|
||||
1
src/lib/assets/favicon.svg
Normal file
1
src/lib/assets/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
1
src/lib/index.ts
Normal file
1
src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
35
src/lib/server/auth.ts
Normal file
35
src/lib/server/auth.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import db from './db';
|
||||
|
||||
export async function hashPassword(password: string) {
|
||||
return await bcrypt.hash(password, 10);
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string) {
|
||||
return await bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
export function createSession(userId: number, username: string, isAdmin: boolean, cookies: any) {
|
||||
const sessionToken = crypto.randomUUID();
|
||||
cookies.set('session', JSON.stringify({ userId, username, isAdmin }), {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 60 * 60 * 24 * 7 // 1 week
|
||||
});
|
||||
}
|
||||
|
||||
export function getSession(cookies: any) {
|
||||
const session = cookies.get('session');
|
||||
if (!session) return null;
|
||||
try {
|
||||
return JSON.parse(session);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function removeSession(cookies: any) {
|
||||
cookies.delete('session', { path: '/' });
|
||||
}
|
||||
76
src/lib/server/db.ts
Normal file
76
src/lib/server/db.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { join } from 'path';
|
||||
|
||||
const dbPath = join(process.cwd(), 'data.db');
|
||||
const db = new Database(dbPath);
|
||||
|
||||
// Initialize schema
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS words (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
word TEXT UNIQUE NOT NULL,
|
||||
meaning TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mistakes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
word_id INTEGER NOT NULL,
|
||||
wrong_count INTEGER DEFAULT 1,
|
||||
last_wrong_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (word_id) REFERENCES words(id),
|
||||
UNIQUE(user_id, word_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_stats (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
total_answered INTEGER DEFAULT 0,
|
||||
correct_count INTEGER DEFAULT 0,
|
||||
max_streak INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
`);
|
||||
|
||||
// Seed initial words if empty
|
||||
const wordCount = db.prepare('SELECT COUNT(*) as count FROM words').get() as { count: number };
|
||||
if (wordCount.count === 0) {
|
||||
const insert = db.prepare('INSERT INTO words (word, meaning) VALUES (?, ?)');
|
||||
const initialWords = [
|
||||
["abandon", "v. 放弃,抛弃"],
|
||||
["abnormal", "adj. 反常的,异常的"],
|
||||
["abolish", "v. 废除,废止"],
|
||||
["abrupt", "adj. 突然的,意外的"],
|
||||
["absurd", "adj. 荒谬的,可笑的"],
|
||||
["abundant", "adj. 丰富的,充裕的"],
|
||||
["academy", "n. 学院,研究院"],
|
||||
["accelerate", "v. 加速,促进"],
|
||||
["accommodate", "v. 容纳,提供住宿"],
|
||||
["accompany", "v. 陪伴,伴随"],
|
||||
["accomplish", "v. 完成,实现"],
|
||||
["accumulate", "v. 积累,积聚"],
|
||||
["accurate", "adj. 准确的,精确的"],
|
||||
["accuse", "v. 指控,谴责"],
|
||||
["custom", "n. 习惯,风俗"],
|
||||
["curious", "adj. 好奇的"],
|
||||
["crucial", "adj. 至关重要的"],
|
||||
["compromise", "v./n. 妥协,折中"],
|
||||
["comprehend", "v. 理解,领会"],
|
||||
["budget", "n. 预算"]
|
||||
];
|
||||
|
||||
const transaction = db.transaction((words) => {
|
||||
for (const [w, m] of words) insert.run(w, m);
|
||||
});
|
||||
transaction(initialWords);
|
||||
}
|
||||
|
||||
export default db;
|
||||
5
src/routes/+layout.svelte
Normal file
5
src/routes/+layout.svelte
Normal file
@@ -0,0 +1,5 @@
|
||||
<script>
|
||||
import "../app.css";
|
||||
</script>
|
||||
|
||||
<slot />
|
||||
14
src/routes/+page.server.ts
Normal file
14
src/routes/+page.server.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import db from '$lib/server/db';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const userId = locals.user?.userId;
|
||||
const stats = db.prepare('SELECT * FROM user_stats WHERE user_id = ?').get(userId) as any;
|
||||
const mistakeCount = db.prepare('SELECT COUNT(*) as count FROM mistakes WHERE user_id = ?').get(userId) as { count: number };
|
||||
|
||||
return {
|
||||
user: locals.user,
|
||||
stats,
|
||||
mistakeCount: mistakeCount.count
|
||||
};
|
||||
};
|
||||
98
src/routes/+page.svelte
Normal file
98
src/routes/+page.svelte
Normal file
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import { Trophy, Flame, BookOpen, Settings, Play, LogOut, AlertCircle } from 'lucide-svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData } from './$types';
|
||||
let { data } = $props<{ data: PageData }>();
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen p-4 md:p-8">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<!-- Header -->
|
||||
<header class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-indigo-600">你好, {data.user?.username}</h1>
|
||||
<p class="text-slate-500 text-lg">准备好今天的挑战了吗?</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
{#if data.user?.isAdmin}
|
||||
<a href="/admin/import" class="p-2 bg-white rounded-full shadow-sm text-slate-400 hover:text-indigo-600 transition-colors" title="管理后台">
|
||||
<Settings size={24} />
|
||||
</a>
|
||||
{/if}
|
||||
<form action="/api/auth?/logout" method="POST" use:enhance>
|
||||
<button type="submit" class="p-2 text-slate-400 hover:text-red-500 transition-colors" title="退出登录">
|
||||
<LogOut size={24} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
|
||||
<div class="bg-white p-6 rounded-3xl shadow-sm border border-slate-100 flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-indigo-100 text-indigo-600 rounded-2xl flex items-center justify-center">
|
||||
<Trophy size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-slate-500 font-medium uppercase">正确数</p>
|
||||
<p class="text-2xl font-bold">{data.stats?.correct_count || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white p-6 rounded-3xl shadow-sm border border-slate-100 flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-orange-100 text-orange-600 rounded-2xl flex items-center justify-center">
|
||||
<Flame size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-slate-500 font-medium uppercase">最高连击</p>
|
||||
<p class="text-2xl font-bold">{data.stats?.max_streak || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white p-6 rounded-3xl shadow-sm border border-slate-100 flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-green-100 text-green-600 rounded-2xl flex items-center justify-center">
|
||||
<BookOpen size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-slate-500 font-medium uppercase">总答题数</p>
|
||||
<p class="text-2xl font-bold">{data.stats?.total_answered || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<a href="/challenge" class="group relative bg-gradient-to-br from-indigo-500 to-indigo-700 p-8 rounded-[2.5rem] text-white shadow-xl hover:shadow-indigo-200 transition-all overflow-hidden">
|
||||
<div class="relative z-10">
|
||||
<Play size={48} class="mb-4" />
|
||||
<h2 class="text-3xl font-bold mb-2">单词挑战</h2>
|
||||
<p class="text-indigo-100 text-lg">随机挑战,提升词汇量</p>
|
||||
</div>
|
||||
<div class="absolute -right-12 -bottom-12 transform rotate-12 opacity-10 group-hover:scale-110 transition-transform">
|
||||
<Trophy size={240} />
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="space-y-6">
|
||||
<a href="/mistakes" class="block bg-white p-6 rounded-3xl shadow-sm border border-slate-100 hover:border-indigo-300 transition-all">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="w-10 h-10 bg-red-100 text-red-600 rounded-xl flex items-center justify-center">
|
||||
<AlertCircle size={20} />
|
||||
</div>
|
||||
<span class="bg-red-50 text-red-600 px-3 py-1 rounded-full text-sm font-bold">{data.mistakeCount} 词</span>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-1">错题本</h3>
|
||||
<p class="text-slate-500">查看并巩固你答错的单词</p>
|
||||
</a>
|
||||
|
||||
<a href="/practice" class="block bg-white p-6 rounded-3xl shadow-sm border border-slate-100 hover:border-indigo-300 transition-all">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="w-10 h-10 bg-purple-100 text-purple-600 rounded-xl flex items-center justify-center">
|
||||
<Settings size={20} />
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-1">专刷错题</h3>
|
||||
<p class="text-slate-500">针对易错点进行专项突破</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
80
src/routes/admin/import/+page.server.ts
Normal file
80
src/routes/admin/import/+page.server.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { fail, type Actions } from '@sveltejs/kit';
|
||||
import db from '$lib/server/db';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
export const actions: Actions = {
|
||||
import: async ({ request, locals }) => {
|
||||
if (!locals.user?.isAdmin) {
|
||||
return fail(403, { message: '只有管理员可以导入词库' });
|
||||
}
|
||||
|
||||
const data = await request.formData();
|
||||
const file = data.get('file') as File;
|
||||
const textContent = data.get('content') as string;
|
||||
|
||||
let wordsToImport: { word: string; meaning: string }[] = [];
|
||||
|
||||
// Handle Excel File
|
||||
if (file && file.size > 0 && (file.name.endsWith('.xlsx') || file.name.endsWith('.xls'))) {
|
||||
try {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const workbook = XLSX.read(buffer, { type: 'array' });
|
||||
const firstSheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
const jsonData = XLSX.utils.sheet_to_json(firstSheet, { header: 1 }) as any[][];
|
||||
|
||||
for (const row of jsonData) {
|
||||
if (row[0] && row[1]) {
|
||||
wordsToImport.push({
|
||||
word: String(row[0]).trim(),
|
||||
meaning: String(row[1]).trim()
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
return fail(400, { message: 'Excel 解析失败: ' + err.message });
|
||||
}
|
||||
}
|
||||
// Handle Text Area
|
||||
else if (textContent) {
|
||||
const lines = [...new Set(textContent.split('\n'))];
|
||||
for (const line of lines) {
|
||||
const [word, ...rest] = line.split(':');
|
||||
if (word && rest.length > 0) {
|
||||
wordsToImport.push({
|
||||
word: word.trim(),
|
||||
meaning: rest.join(':').trim()
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return fail(400, { message: '请选择文件或输入内容' });
|
||||
}
|
||||
|
||||
if (wordsToImport.length === 0) {
|
||||
return fail(400, { message: '未找到可导入的数据' });
|
||||
}
|
||||
|
||||
let addedCount = 0;
|
||||
const upsert = db.prepare(`
|
||||
INSERT INTO words (word, meaning)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(word) DO UPDATE SET meaning = excluded.meaning
|
||||
`);
|
||||
|
||||
const transaction = db.transaction((items) => {
|
||||
for (const item of items) {
|
||||
const result = upsert.run(item.word, item.meaning);
|
||||
if (result.changes > 0) {
|
||||
addedCount++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
transaction(wordsToImport);
|
||||
return { success: true, count: addedCount };
|
||||
} catch (err: any) {
|
||||
return fail(500, { message: '导入失败: ' + err.message });
|
||||
}
|
||||
}
|
||||
};
|
||||
107
src/routes/admin/import/+page.svelte
Normal file
107
src/routes/admin/import/+page.svelte
Normal file
@@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { FileUp, Home, CheckCircle2, AlertCircle } from 'lucide-svelte';
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
|
||||
let { form } = $props<{ form: any }>();
|
||||
let importing = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen p-4 md:p-8">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<header class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-indigo-600">批量导入词库</h1>
|
||||
<p class="text-slate-500">管理员专用后台</p>
|
||||
</div>
|
||||
<a href="/" class="p-2 bg-white rounded-full shadow-sm text-slate-400 hover:text-indigo-600 transition-colors">
|
||||
<Home size={24} />
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<div class="bg-white rounded-3xl shadow-xl p-8">
|
||||
<div class="mb-6 p-4 bg-indigo-50 rounded-2xl text-indigo-700 text-sm">
|
||||
<p class="font-bold mb-1">导入说明:</p>
|
||||
<p>1. **粘贴文本**:每行一个单词,格式为 `单词: 解释`</p>
|
||||
<p>2. **上传 Excel**:首列为单词,次列为解释(支持 .xlsx, .xls)。</p>
|
||||
<p class="mt-2 text-xs">
|
||||
<a href="/import_template.xlsx" download class="bg-indigo-100 text-indigo-700 px-2 py-1 rounded hover:bg-indigo-200 font-bold">
|
||||
下载 Excel 案例模板
|
||||
</a>
|
||||
</p>
|
||||
<p class="mt-2 text-indigo-500 font-medium">注意:若单词已存在,将自动更新其释义。</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="?/import" enctype="multipart/form-data" use:enhance={() => {
|
||||
importing = true;
|
||||
return async ({ update }) => {
|
||||
importing = false;
|
||||
update();
|
||||
};
|
||||
}} class="space-y-6">
|
||||
<!-- File Upload -->
|
||||
<div class="space-y-2">
|
||||
<label for="file" class="block text-sm font-medium text-slate-700">方法一:上传 Excel 文件</label>
|
||||
<div class="relative group">
|
||||
<input
|
||||
type="file"
|
||||
name="file"
|
||||
id="file"
|
||||
accept=".xlsx, .xls"
|
||||
class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
|
||||
/>
|
||||
<div class="p-6 border-2 border-dashed border-slate-200 rounded-2xl bg-slate-50 group-hover:bg-indigo-50 group-hover:border-indigo-300 transition-all text-center">
|
||||
<FileUp class="mx-auto mb-2 text-slate-400 group-hover:text-indigo-500" size={32} />
|
||||
<p class="text-sm text-slate-500 group-hover:text-indigo-600">点击或直接拖拽 Excel 文件到这里</p>
|
||||
<p class="text-xs text-slate-400 mt-1">支持 .xlsx 和 .xls 格式</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative flex items-center py-2">
|
||||
<div class="flex-grow border-t border-slate-200"></div>
|
||||
<span class="flex-shrink mx-4 text-slate-400 text-xs uppercase font-bold tracking-widest">或</span>
|
||||
<div class="flex-grow border-t border-slate-200"></div>
|
||||
</div>
|
||||
|
||||
<!-- Text Area -->
|
||||
<div>
|
||||
<label for="content" class="block text-sm font-medium text-slate-700 mb-2">方法二:直接粘贴文本内容</label>
|
||||
<textarea
|
||||
name="content"
|
||||
id="content"
|
||||
rows="8"
|
||||
class="w-full p-4 bg-slate-50 border border-slate-200 rounded-2xl focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all font-mono text-sm"
|
||||
placeholder="apple: 苹果 banana: 香蕉"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
{#if form?.success}
|
||||
<div in:fade class="flex items-center gap-3 p-4 bg-green-50 text-green-700 rounded-2xl">
|
||||
<CheckCircle2 size={20} />
|
||||
<span>操作成功:共处理 {form.count} 条数据(已自动去重并更新)</span>
|
||||
</div>
|
||||
{:else if form?.message}
|
||||
<div in:fade class="flex items-center gap-3 p-4 bg-red-50 text-red-700 rounded-2xl">
|
||||
<AlertCircle size={20} />
|
||||
<span>{form.message}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={importing}
|
||||
class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-4 rounded-2xl shadow-lg transition-all flex items-center justify-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
{#if importing}
|
||||
<div class="animate-spin w-5 h-5 border-2 border-white border-t-transparent rounded-full"></div>
|
||||
导入中...
|
||||
{:else}
|
||||
<FileUp size={20} />
|
||||
开始导入
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
9
src/routes/api/auth/+page.server.ts
Normal file
9
src/routes/api/auth/+page.server.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { redirect, type Actions } from '@sveltejs/kit';
|
||||
import { removeSession } from '$lib/server/auth';
|
||||
|
||||
export const actions: Actions = {
|
||||
logout: ({ cookies }) => {
|
||||
removeSession(cookies);
|
||||
throw redirect(303, '/login');
|
||||
}
|
||||
};
|
||||
52
src/routes/api/challenge/+server.ts
Normal file
52
src/routes/api/challenge/+server.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import db from '$lib/server/db';
|
||||
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
if (!locals.user) return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
// Fetch 1 random target word
|
||||
const targetWord = db.prepare('SELECT * FROM words ORDER BY RANDOM() LIMIT 1').get() as any;
|
||||
|
||||
// Fetch 3 random distractors
|
||||
const distractors = db.prepare('SELECT meaning FROM words WHERE id != ? ORDER BY RANDOM() LIMIT 3')
|
||||
.all(targetWord.id) as any[];
|
||||
|
||||
return json({
|
||||
word: targetWord.word,
|
||||
wordId: targetWord.id,
|
||||
meaning: targetWord.meaning,
|
||||
options: [targetWord.meaning, ...distractors.map(d => d.meaning)]
|
||||
});
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ locals, request }) => {
|
||||
if (!locals.user) return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { wordId, isCorrect, streak } = await request.json();
|
||||
const userId = locals.user.userId;
|
||||
|
||||
// Update global stats
|
||||
db.prepare(`
|
||||
UPDATE user_stats
|
||||
SET total_answered = total_answered + 1,
|
||||
correct_count = correct_count + (CASE WHEN ? = 1 THEN 1 ELSE 0 END),
|
||||
max_streak = MAX(max_streak, ?)
|
||||
WHERE user_id = ?
|
||||
`).run(isCorrect ? 1 : 0, streak, userId);
|
||||
|
||||
// Update mistakes if incorrect
|
||||
if (!isCorrect) {
|
||||
db.prepare(`
|
||||
INSERT INTO mistakes (user_id, word_id, wrong_count)
|
||||
VALUES (?, ?, 1)
|
||||
ON CONFLICT(user_id, word_id) DO UPDATE SET
|
||||
wrong_count = wrong_count + 1,
|
||||
last_wrong_at = CURRENT_TIMESTAMP
|
||||
`).run(userId, wordId);
|
||||
} else {
|
||||
// If correct, maybe remove or reduce mistake count?
|
||||
// For now, let's just keep them.
|
||||
}
|
||||
|
||||
return json({ success: true });
|
||||
};
|
||||
31
src/routes/api/practice/+server.ts
Normal file
31
src/routes/api/practice/+server.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import db from '$lib/server/db';
|
||||
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
if (!locals.user) return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const userId = locals.user.userId;
|
||||
|
||||
// Fetch 1 random word from the user's mistakes
|
||||
const targetMistake = db.prepare(`
|
||||
SELECT w.* FROM mistakes m
|
||||
JOIN words w ON m.word_id = w.id
|
||||
WHERE m.user_id = ?
|
||||
ORDER BY RANDOM() LIMIT 1
|
||||
`).get(userId) as any;
|
||||
|
||||
if (!targetMistake) {
|
||||
return json({ error: 'No mistakes found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Fetch 3 random distractors (could be any relative words)
|
||||
const distractors = db.prepare('SELECT meaning FROM words WHERE id != ? ORDER BY RANDOM() LIMIT 3')
|
||||
.all(targetMistake.id) as any[];
|
||||
|
||||
return json({
|
||||
word: targetMistake.word,
|
||||
wordId: targetMistake.id,
|
||||
meaning: targetMistake.meaning,
|
||||
options: [targetMistake.meaning, ...distractors.map(d => d.meaning)]
|
||||
});
|
||||
};
|
||||
196
src/routes/challenge/+page.svelte
Normal file
196
src/routes/challenge/+page.svelte
Normal file
@@ -0,0 +1,196 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Trophy, Flame, CheckCircle2, XCircle, ArrowRight, Volume2, Home } from 'lucide-svelte';
|
||||
import { fade, fly, scale } from 'svelte/transition';
|
||||
|
||||
let currentQuestion = $state<any>(null);
|
||||
let options = $state<string[]>([]);
|
||||
let selectedOption = $state<string | null>(null);
|
||||
let isAnswered = $state(false);
|
||||
|
||||
let score = $state(0);
|
||||
let streak = $state(0);
|
||||
let maxStreak = $state(0);
|
||||
let totalAnswered = $state(0);
|
||||
let loading = $state(true);
|
||||
|
||||
const labels = ['A', 'B', 'C', 'D'];
|
||||
|
||||
async function fetchNextQuestion() {
|
||||
loading = true;
|
||||
isAnswered = false;
|
||||
selectedOption = null;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/challenge');
|
||||
currentQuestion = await res.json();
|
||||
options = shuffleArray(currentQuestion.options);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch question', err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function shuffleArray(array: any[]) {
|
||||
const newArray = [...array];
|
||||
for (let i = newArray.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[newArray[i], newArray[j]] = [newArray[j], newArray[i]];
|
||||
}
|
||||
return newArray;
|
||||
}
|
||||
|
||||
function pronounceWord(text: string) {
|
||||
if (typeof window !== 'undefined' && 'speechSynthesis' in window) {
|
||||
window.speechSynthesis.cancel();
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
utterance.lang = 'en-US';
|
||||
utterance.rate = 0.9;
|
||||
window.speechSynthesis.speak(utterance);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOptionClick(option: string) {
|
||||
if (isAnswered) return;
|
||||
|
||||
selectedOption = option;
|
||||
isAnswered = true;
|
||||
totalAnswered += 1;
|
||||
|
||||
const isCorrect = option === currentQuestion.meaning;
|
||||
|
||||
if (isCorrect) {
|
||||
score += 1;
|
||||
streak += 1;
|
||||
if (streak > maxStreak) maxStreak = streak;
|
||||
} else {
|
||||
streak = 0;
|
||||
}
|
||||
|
||||
// Report to API
|
||||
await fetch('/api/challenge', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
wordId: currentQuestion.wordId,
|
||||
isCorrect,
|
||||
streak
|
||||
})
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
fetchNextQuestion();
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
function getOptionStyle(option: string) {
|
||||
if (!isAnswered) {
|
||||
return "bg-white text-slate-700 hover:bg-indigo-50 hover:border-indigo-300 border-slate-200";
|
||||
}
|
||||
if (option === currentQuestion.meaning) {
|
||||
return "bg-green-100 border-green-500 text-green-800 font-semibold shadow-sm";
|
||||
}
|
||||
if (option === selectedOption && option !== currentQuestion.meaning) {
|
||||
return "bg-red-100 border-red-500 text-red-800";
|
||||
}
|
||||
return "bg-slate-50 border-slate-200 text-slate-400 opacity-60";
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchNextQuestion();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-slate-100 flex items-center justify-center p-4">
|
||||
<div class="max-w-md w-full">
|
||||
<!-- Top Bar -->
|
||||
<div class="flex justify-between items-center mb-6 px-2">
|
||||
<a href="/" class="p-2 bg-white rounded-full shadow-sm text-slate-400 hover:text-indigo-600 transition-colors">
|
||||
<Home size={20} />
|
||||
</a>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex items-center space-x-2 text-indigo-600 bg-white px-3 py-1.5 rounded-full shadow-sm">
|
||||
<Trophy size={18} />
|
||||
<span class="font-bold text-sm">{score} / {totalAnswered}</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 text-orange-500 bg-white px-3 py-1.5 rounded-full shadow-sm">
|
||||
<Flame size={18} class={streak > 2 ? "animate-pulse" : ""} />
|
||||
<span class="font-bold text-sm">{streak}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading && !currentQuestion}
|
||||
<div class="bg-white rounded-3xl shadow-xl p-12 text-center" in:fade>
|
||||
<div class="animate-spin w-10 h-10 border-4 border-indigo-500 border-t-transparent rounded-full mx-auto mb-4"></div>
|
||||
<p class="text-slate-500">准备题目中...</p>
|
||||
</div>
|
||||
{:else if currentQuestion}
|
||||
<div class="bg-white rounded-3xl shadow-xl overflow-hidden" in:fly={{ y: 20 }}>
|
||||
<!-- Word Area -->
|
||||
<div class="bg-gradient-to-br from-indigo-500 to-purple-600 p-8 text-center text-white relative">
|
||||
<h2 class="text-sm font-medium opacity-80 mb-2 tracking-wider uppercase">选择正确的中文释义</h2>
|
||||
|
||||
<div class="flex items-center justify-center gap-3 mb-2">
|
||||
<h1 class="text-5xl font-extrabold tracking-tight drop-shadow-md">
|
||||
{currentQuestion.word}
|
||||
</h1>
|
||||
<button
|
||||
onclick={() => pronounceWord(currentQuestion.word)}
|
||||
class="p-2 rounded-full bg-white/20 hover:bg-white/40 transition-colors backdrop-blur-sm cursor-pointer"
|
||||
>
|
||||
<Volume2 size={28} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if isAnswered}
|
||||
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 pointer-events-none" in:scale={{ duration: 300 }}>
|
||||
{#if selectedOption === currentQuestion.meaning}
|
||||
<CheckCircle2 size={120} class="text-green-300 opacity-40" />
|
||||
{:else}
|
||||
<XCircle size={120} class="text-red-300 opacity-40" />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Options -->
|
||||
<div class="p-6 space-y-3">
|
||||
{#each options as option, index}
|
||||
<button
|
||||
onclick={() => handleOptionClick(option)}
|
||||
disabled={isAnswered}
|
||||
class="w-full text-left p-4 rounded-xl border-2 transition-all duration-200 flex items-center group {getOptionStyle(option)}"
|
||||
>
|
||||
<span class="w-8 h-8 rounded-lg flex items-center justify-center font-bold mr-4 text-sm transition-colors
|
||||
{!isAnswered ? 'bg-slate-100 text-slate-500 group-hover:bg-indigo-100 group-hover:text-indigo-600' :
|
||||
(option === currentQuestion.meaning ? 'bg-green-200 text-green-800' :
|
||||
(option === selectedOption ? 'bg-red-200 text-red-800' : 'bg-slate-100 text-slate-400'))}
|
||||
">
|
||||
{labels[index]}
|
||||
</span>
|
||||
<span class="text-lg flex-1">{option}</span>
|
||||
|
||||
{#if isAnswered && option === currentQuestion.meaning}
|
||||
<CheckCircle2 size={20} class="text-green-600 ml-2" />
|
||||
{/if}
|
||||
{#if isAnswered && option === selectedOption && option !== currentQuestion.meaning}
|
||||
<XCircle size={20} class="text-red-500 ml-2" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-50 p-4 text-center text-sm text-slate-500 border-t border-slate-100 h-14 flex items-center justify-center">
|
||||
{#if isAnswered}
|
||||
<span class="flex items-center text-indigo-500 animate-pulse">
|
||||
准备进入下一题 <ArrowRight size={16} class="ml-1" />
|
||||
</span>
|
||||
{:else}
|
||||
点击选项进行答题
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
25
src/routes/login/+page.server.ts
Normal file
25
src/routes/login/+page.server.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import db from '$lib/server/db';
|
||||
import { verifyPassword, createSession } from '$lib/server/auth';
|
||||
import type { Actions } from './$types';
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, cookies }) => {
|
||||
const data = await request.formData();
|
||||
const username = data.get('username') as string;
|
||||
const password = data.get('password') as string;
|
||||
|
||||
if (!username || !password) {
|
||||
return fail(400, { message: '用户名和密码不能为空' });
|
||||
}
|
||||
|
||||
const user = db.prepare('SELECT id, username, password_hash, is_admin FROM users WHERE username = ?').get(username) as any;
|
||||
|
||||
if (!user || !(await verifyPassword(password, user.password_hash))) {
|
||||
return fail(400, { message: '用户名或密码错误' });
|
||||
}
|
||||
|
||||
createSession(user.id, user.username, user.is_admin === 1, cookies);
|
||||
throw redirect(303, '/');
|
||||
}
|
||||
};
|
||||
45
src/routes/login/+page.svelte
Normal file
45
src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { User, Lock, LogIn } from 'lucide-svelte';
|
||||
let { form } = $props();
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center p-4">
|
||||
<div class="max-w-md w-full bg-white rounded-3xl shadow-xl p-8">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-indigo-600 mb-2">欢迎回来</h1>
|
||||
<p class="text-slate-500">继续你的单词挑战</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" use:enhance class="space-y-6">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-slate-700 mb-1">用户名</label>
|
||||
<div class="relative">
|
||||
<User class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||
<input name="username" id="username" type="text" required class="w-full pl-10 pr-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all" placeholder="请输入用户名" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-slate-700 mb-1">密码</label>
|
||||
<div class="relative">
|
||||
<Lock class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||
<input name="password" id="password" type="password" required class="w-full pl-10 pr-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all" placeholder="请输入密码" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if form?.message}
|
||||
<p class="text-red-500 text-sm text-center bg-red-50 py-2 rounded-lg">{form.message}</p>
|
||||
{/if}
|
||||
|
||||
<button type="submit" class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-3 rounded-xl shadow-lg transition-all flex items-center justify-center gap-2">
|
||||
<LogIn size={20} />
|
||||
立即登录
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-6 text-center text-slate-500">
|
||||
还没有账号? <a href="/register" class="text-indigo-600 font-semibold hover:underline">立即注册</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
16
src/routes/mistakes/+page.server.ts
Normal file
16
src/routes/mistakes/+page.server.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import db from '$lib/server/db';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const userId = locals.user?.userId;
|
||||
|
||||
const mistakes = db.prepare(`
|
||||
SELECT m.*, w.word, w.meaning
|
||||
FROM mistakes m
|
||||
JOIN words w ON m.word_id = w.id
|
||||
WHERE m.user_id = ?
|
||||
ORDER BY m.last_wrong_at DESC
|
||||
`).all(userId) as any[];
|
||||
|
||||
return { mistakes };
|
||||
};
|
||||
64
src/routes/mistakes/+page.svelte
Normal file
64
src/routes/mistakes/+page.svelte
Normal file
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import { ChevronLeft, Volume2, Calendar } from 'lucide-svelte';
|
||||
import type { PageData } from './$types';
|
||||
let { data } = $props<{ data: PageData }>();
|
||||
|
||||
function pronounceWord(text: string) {
|
||||
if (typeof window !== 'undefined' && 'speechSynthesis' in window) {
|
||||
window.speechSynthesis.cancel();
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
utterance.lang = 'en-US';
|
||||
window.speechSynthesis.speak(utterance);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleDateString('zh-CN', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen p-4 md:p-8">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<header class="flex items-center gap-4 mb-8">
|
||||
<a href="/" class="p-2 bg-white rounded-xl shadow-sm border border-slate-100 text-slate-500 hover:text-indigo-600 transition-colors">
|
||||
<ChevronLeft size={24} />
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold">错题本</h1>
|
||||
</header>
|
||||
|
||||
{#if data.mistakes.length === 0}
|
||||
<div class="bg-white rounded-3xl p-12 text-center shadow-sm border border-slate-100">
|
||||
<p class="text-slate-500">太棒了!你还没有错题。</p>
|
||||
<a href="/challenge" class="mt-4 inline-block text-indigo-600 font-semibold">开始挑战以查缺补漏</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each data.mistakes as m}
|
||||
<div class="bg-white p-6 rounded-2xl shadow-sm border border-slate-100 flex items-center justify-between group hover:border-indigo-200 transition-all">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<h3 class="text-xl font-bold text-indigo-600 tracking-tight">{m.word}</h3>
|
||||
<button onclick={() => pronounceWord(m.word)} class="p-1 text-slate-300 hover:text-indigo-400 transition-colors">
|
||||
<Volume2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-slate-600">{m.meaning}</p>
|
||||
<div class="flex items-center gap-4 mt-3">
|
||||
<span class="text-xs bg-red-50 text-red-500 px-2 py-0.5 rounded-full font-bold">错误 {m.wrong_count} 次</span>
|
||||
<span class="text-xs text-slate-400 flex items-center gap-1">
|
||||
<Calendar size={12} />
|
||||
{formatDate(m.last_wrong_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
207
src/routes/practice/+page.svelte
Normal file
207
src/routes/practice/+page.svelte
Normal file
@@ -0,0 +1,207 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Trophy, Flame, CheckCircle2, XCircle, ArrowRight, Volume2, Home, AlertCircle } from 'lucide-svelte';
|
||||
import { fade, fly, scale } from 'svelte/transition';
|
||||
|
||||
let currentQuestion = $state<any>(null);
|
||||
let options = $state<string[]>([]);
|
||||
let selectedOption = $state<string | null>(null);
|
||||
let isAnswered = $state(false);
|
||||
|
||||
let score = $state(0);
|
||||
let streak = $state(0);
|
||||
let totalAnswered = $state(0);
|
||||
let loading = $state(true);
|
||||
let noMistakes = $state(false);
|
||||
|
||||
const labels = ['A', 'B', 'C', 'D'];
|
||||
|
||||
async function fetchNextQuestion() {
|
||||
loading = true;
|
||||
isAnswered = false;
|
||||
selectedOption = null;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/practice');
|
||||
if (res.status === 404) {
|
||||
noMistakes = true;
|
||||
return;
|
||||
}
|
||||
currentQuestion = await res.json();
|
||||
options = shuffleArray(currentQuestion.options);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch question', err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function shuffleArray(array: any[]) {
|
||||
const newArray = [...array];
|
||||
for (let i = newArray.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[newArray[i], newArray[j]] = [newArray[j], newArray[i]];
|
||||
}
|
||||
return newArray;
|
||||
}
|
||||
|
||||
function pronounceWord(text: string) {
|
||||
if (typeof window !== 'undefined' && 'speechSynthesis' in window) {
|
||||
window.speechSynthesis.cancel();
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
utterance.lang = 'en-US';
|
||||
window.speechSynthesis.speak(utterance);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOptionClick(option: string) {
|
||||
if (isAnswered) return;
|
||||
|
||||
selectedOption = option;
|
||||
isAnswered = true;
|
||||
totalAnswered += 1;
|
||||
|
||||
const isCorrect = option === currentQuestion.meaning;
|
||||
|
||||
if (isCorrect) {
|
||||
score += 1;
|
||||
streak += 1;
|
||||
} else {
|
||||
streak = 0;
|
||||
}
|
||||
|
||||
// Report to API (reuse challenge API as it handles mistake logic too)
|
||||
await fetch('/api/challenge', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
wordId: currentQuestion.wordId,
|
||||
isCorrect,
|
||||
streak
|
||||
})
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
fetchNextQuestion();
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
function getOptionStyle(option: string) {
|
||||
if (!isAnswered) {
|
||||
return "bg-white text-slate-700 hover:bg-indigo-50 hover:border-indigo-300 border-slate-200";
|
||||
}
|
||||
if (option === currentQuestion.meaning) {
|
||||
return "bg-green-100 border-green-500 text-green-800 font-semibold shadow-sm";
|
||||
}
|
||||
if (option === selectedOption && option !== currentQuestion.meaning) {
|
||||
return "bg-red-100 border-red-500 text-red-800";
|
||||
}
|
||||
return "bg-slate-50 border-slate-200 text-slate-400 opacity-60";
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchNextQuestion();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-slate-100 flex items-center justify-center p-4">
|
||||
<div class="max-w-md w-full">
|
||||
{#if noMistakes}
|
||||
<div class="bg-white rounded-3xl shadow-xl p-12 text-center" in:scale>
|
||||
<div class="w-16 h-16 bg-green-100 text-green-600 rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||
<CheckCircle2 size={32} />
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold mb-2">太棒了!</h2>
|
||||
<p class="text-slate-500 mb-8">你已经清除了所有错题,或者还没有错题记录。</p>
|
||||
<a href="/" class="inline-block bg-indigo-600 text-white px-8 py-3 rounded-xl font-bold shadow-lg">返回首页</a>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Top Bar -->
|
||||
<div class="flex justify-between items-center mb-6 px-2">
|
||||
<a href="/" class="p-2 bg-white rounded-full shadow-sm text-slate-400 hover:text-indigo-600 transition-colors">
|
||||
<Home size={20} />
|
||||
</a>
|
||||
<div class="flex items-center gap-2 bg-purple-100 text-purple-700 px-3 py-1.5 rounded-full text-xs font-bold uppercase tracking-wider">
|
||||
<AlertCircle size={14} />
|
||||
专刷错题模式
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 text-indigo-600 bg-white px-3 py-1.5 rounded-full shadow-sm">
|
||||
<Trophy size={18} />
|
||||
<span class="font-bold text-sm">{score} / {totalAnswered}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading && !currentQuestion}
|
||||
<div class="bg-white rounded-3xl shadow-xl p-12 text-center" in:fade>
|
||||
<div class="animate-spin w-10 h-10 border-4 border-indigo-500 border-t-transparent rounded-full mx-auto mb-4"></div>
|
||||
<p class="text-slate-500">寻找错题中...</p>
|
||||
</div>
|
||||
{:else if currentQuestion}
|
||||
<div class="bg-white rounded-3xl shadow-xl overflow-hidden" in:fly={{ y: 20 }}>
|
||||
<!-- Word Area -->
|
||||
<div class="bg-gradient-to-br from-purple-500 to-indigo-600 p-8 text-center text-white relative">
|
||||
<h2 class="text-sm font-medium opacity-80 mb-2 tracking-wider uppercase">强化记忆:该单词曾答错</h2>
|
||||
|
||||
<div class="flex items-center justify-center gap-3 mb-2">
|
||||
<h1 class="text-5xl font-extrabold tracking-tight drop-shadow-md">
|
||||
{currentQuestion.word}
|
||||
</h1>
|
||||
<button
|
||||
onclick={() => pronounceWord(currentQuestion.word)}
|
||||
class="p-2 rounded-full bg-white/20 hover:bg-white/40 transition-colors backdrop-blur-sm cursor-pointer"
|
||||
>
|
||||
<Volume2 size={28} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if isAnswered}
|
||||
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 pointer-events-none" in:scale={{ duration: 300 }}>
|
||||
{#if selectedOption === currentQuestion.meaning}
|
||||
<CheckCircle2 size={120} class="text-green-300 opacity-40" />
|
||||
{:else}
|
||||
<XCircle size={120} class="text-red-300 opacity-40" />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Options -->
|
||||
<div class="p-6 space-y-3">
|
||||
{#each options as option, index}
|
||||
<button
|
||||
onclick={() => handleOptionClick(option)}
|
||||
disabled={isAnswered}
|
||||
class="w-full text-left p-4 rounded-xl border-2 transition-all duration-200 flex items-center group {getOptionStyle(option)}"
|
||||
>
|
||||
<span class="w-8 h-8 rounded-lg flex items-center justify-center font-bold mr-4 text-sm transition-colors
|
||||
{!isAnswered ? 'bg-slate-100 text-slate-500 group-hover:bg-indigo-100 group-hover:text-indigo-600' :
|
||||
(option === currentQuestion.meaning ? 'bg-green-200 text-green-800' :
|
||||
(option === selectedOption ? 'bg-red-200 text-red-800' : 'bg-slate-100 text-slate-400'))}
|
||||
">
|
||||
{labels[index]}
|
||||
</span>
|
||||
<span class="text-lg flex-1">{option}</span>
|
||||
|
||||
{#if isAnswered && option === currentQuestion.meaning}
|
||||
<CheckCircle2 size={20} class="text-green-600 ml-2" />
|
||||
{/if}
|
||||
{#if isAnswered && option === selectedOption && option !== currentQuestion.meaning}
|
||||
<XCircle size={20} class="text-red-500 ml-2" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-50 p-4 text-center text-sm text-slate-500 border-t border-slate-100 h-14 flex items-center justify-center">
|
||||
{#if isAnswered}
|
||||
<span class="flex items-center text-indigo-500 animate-pulse">
|
||||
准备进入下一题 <ArrowRight size={16} class="ml-1" />
|
||||
</span>
|
||||
{:else}
|
||||
点击选项进行答题
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
41
src/routes/register/+page.server.ts
Normal file
41
src/routes/register/+page.server.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import db from '$lib/server/db';
|
||||
import { hashPassword, createSession } from '$lib/server/auth';
|
||||
import type { Actions } from './$types';
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, cookies }) => {
|
||||
const data = await request.formData();
|
||||
const username = data.get('username') as string;
|
||||
const password = data.get('password') as string;
|
||||
|
||||
if (!username || !password) {
|
||||
return fail(400, { message: '用户名和密码不能为空' });
|
||||
}
|
||||
|
||||
try {
|
||||
const hash = await hashPassword(password);
|
||||
|
||||
// Check if this is the first user
|
||||
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number };
|
||||
const isAdmin = userCount.count === 0 ? 1 : 0;
|
||||
|
||||
const result = db.prepare('INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)')
|
||||
.run(username, hash, isAdmin);
|
||||
|
||||
const userId = result.lastInsertRowid as number;
|
||||
|
||||
// Initialize user stats
|
||||
db.prepare('INSERT INTO user_stats (user_id) VALUES (?)').run(userId);
|
||||
|
||||
createSession(userId, username, isAdmin === 1, cookies);
|
||||
} catch (err: any) {
|
||||
if (err.message.includes('UNIQUE constraint failed')) {
|
||||
return fail(400, { message: '用户名已被占用' });
|
||||
}
|
||||
return fail(500, { message: '注册失败,请稍后再试' });
|
||||
}
|
||||
|
||||
throw redirect(303, '/');
|
||||
}
|
||||
};
|
||||
45
src/routes/register/+page.svelte
Normal file
45
src/routes/register/+page.svelte
Normal file
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { User, Lock, LogIn } from 'lucide-svelte';
|
||||
let { form } = $props();
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center p-4">
|
||||
<div class="max-w-md w-full bg-white rounded-3xl shadow-xl p-8">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-indigo-600 mb-2">创建账号</h1>
|
||||
<p class="text-slate-500">开启你的单词挑战之旅</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" use:enhance class="space-y-6">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-slate-700 mb-1">用户名</label>
|
||||
<div class="relative">
|
||||
<User class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||
<input name="username" id="username" type="text" required class="w-full pl-10 pr-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all" placeholder="请输入用户名" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-slate-700 mb-1">密码</label>
|
||||
<div class="relative">
|
||||
<Lock class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||
<input name="password" id="password" type="password" required class="w-full pl-10 pr-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all" placeholder="请输入密码" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if form?.message}
|
||||
<p class="text-red-500 text-sm text-center bg-red-50 py-2 rounded-lg">{form.message}</p>
|
||||
{/if}
|
||||
|
||||
<button type="submit" class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-3 rounded-xl shadow-lg transition-all flex items-center justify-center gap-2">
|
||||
<LogIn size={20} />
|
||||
注册并登录
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-6 text-center text-slate-500">
|
||||
已有账号? <a href="/login" class="text-indigo-600 font-semibold hover:underline">立即登录</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
BIN
static/import_template.xlsx
Normal file
BIN
static/import_template.xlsx
Normal file
Binary file not shown.
3
static/robots.txt
Normal file
3
static/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
17
svelte.config.js
Normal file
17
svelte.config.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||
adapter: adapter()
|
||||
},
|
||||
vitePlugin: {
|
||||
dynamicCompileOptions: ({ filename }) =>
|
||||
filename.includes('node_modules') ? undefined : { runes: true }
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
7
tailwind.config.js
Normal file
7
tailwind.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||
}
|
||||
9
vite.config.ts
Normal file
9
vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
port: 1995
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user