Initial commit of Vox English challenge system

This commit is contained in:
chaos
2026-03-17 00:05:46 +08:00
commit 8a055daf5e
41 changed files with 4790 additions and 0 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
build
.svelte-kit
.git
.env
data.db
data.db-journal
*.log

27
.gitignore vendored Normal file
View 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

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

40
Dockerfile Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

36
package.json Normal file
View 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
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}

17
src/app.css Normal file
View 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
View 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
View 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
View 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;
};

View 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
View 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
View 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
View 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;

View File

@@ -0,0 +1,5 @@
<script>
import "../app.css";
</script>
<slot />

View 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
View 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>

View 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 });
}
}
};

View 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: 苹果&#10;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>

View 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');
}
};

View 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 });
};

View 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)]
});
};

View 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>

View 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, '/');
}
};

View 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>

View 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 };
};

View 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>

View 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>

View 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, '/');
}
};

View 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

Binary file not shown.

3
static/robots.txt Normal file
View File

@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

17
svelte.config.js Normal file
View 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
View File

@@ -0,0 +1,7 @@
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {},
},
plugins: [],
}

20
tsconfig.json Normal file
View 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
View File

@@ -0,0 +1,9 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: {
port: 1995
}
});