Initial commit: WordLoop 单词学习应用

Vue 前端 + FastAPI 后端,含部署脚本与词典数据。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
John
2026-06-04 14:30:53 -07:00
commit bd7635986a
66 changed files with 5495 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Python
backend/venv/
backend/__pycache__/
backend/**/*.pyc
backend/wordloop.db
backend/.env
backend/data/cache/
# Node
frontend/node_modules/
frontend/dist/
# OS
.DS_Store
Thumbs.db
# IDE
.idea/
.vscode/
# Cloudflare Tunnel(含密钥,勿提交)
deploy/cloudflared/config.yml
deploy/cloudflared/*.json
deploy/secrets.env
+162
View File
@@ -0,0 +1,162 @@
# WordLoop 单词循环记忆系统
输入中文/英文 → 自动翻译 → 加入个人单词库 → 每日测验 → 根据答题情况反复记忆 → 达标后进入已掌握词库。
## 技术栈
- **前端**: Vue 3 + Vite + TypeScript
- **后端**: Python FastAPI
- **数据库**: MySQL(本地 `wordloop` 库)
## 本地启动
端口:**前端 18003** · **后端 18004**
**推荐** 在项目根目录一条命令同时启动:
```bash
chmod +x start.sh # 首次
./start.sh # 或 ./start.sh all
```
也可开两个终端分别执行 `./start.sh backend` / `./start.sh frontend`,或手动进入子目录:
### MySQL
本地需已安装 MySQL,并创建库(账号示例与默认 `.env` 一致):
```bash
mysql -h localhost -u boot -p888888 -e "CREATE DATABASE IF NOT EXISTS wordloop CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
```
连接参数在 `backend/.env`(可从 `backend/.env.example` 复制):
| 变量 | 默认值 |
|------|--------|
| `MYSQL_HOST` | localhost |
| `MYSQL_PORT` | 3306 |
| `MYSQL_USER` | boot |
| `MYSQL_PASSWORD` | 888888 |
| `MYSQL_DATABASE` | wordloop |
首次启动后端时会自动建表。
### 导入离线翻译词典
后端启动后,将内置词条(或自定义词库)导入 `dictionary_entries` 表:
```bash
cd backend
source venv/bin/activate # Windows: venv\Scripts\activate
# 导入默认 seed 词库(36 条)
python -m scripts.import_dictionary
# 导入自定义 JSON / CSV
python -m scripts.import_dictionary --file /path/to/your_dict.json --source ecdict
```
**JSON 格式**(数组或 `{ "entries": [...] }`):
```json
[
{
"lemma_en": "apple",
"zh": "苹果",
"phonetic": "/ˈæpəl/",
"example_en": "I eat an apple every day.",
"example_cn": "我每天吃一个苹果。"
}
]
```
**CSV 列名**`lemma_en`, `zh`, `phonetic`, `example_en`, `example_cn`(也支持 `en` / `cn` 别名)。
翻译 API 会优先查该表;未命中时返回占位结果,暂不接入外部 API。
### 从线上导入 ECDICT 词库(推荐)
自动从 GitHub 下载 [ECDICT](https://github.com/skywind3000/ECDICT) SQLite 词库(约 340 万词条)并写入数据库:
```bash
cd backend
source venv/bin/activate
# 常用词(默认,约 83 万条,含 transformer 等)
python -m scripts.import_dictionary_online
# 核心词(Collins / 牛津,约 1.4 万条,速度快)
python -m scripts.import_dictionary_online --preset core
# 全量(约 338 万条,耗时长、占空间大)
python -m scripts.import_dictionary_online --preset full
# 已有本地 cache 时跳过下载
python -m scripts.import_dictionary_online --skip-download
```
首次运行会下载约 207MB 压缩包并解压到 `backend/data/cache/`(已加入 `.gitignore`)。
### 后端
```bash
cd backend
python -m venv venv
# macOS / Linux
source venv/bin/activate
# Windows
venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env # 首次,按需改密码
uvicorn main:app --reload --host 0.0.0.0 --port 18004
```
API 文档:http://localhost:18004/docs
### 前端
```bash
cd frontend
npm install
npm run dev
```
浏览器访问:http://localhost:18003
## 默认测试账号
注册后即可使用,或自行注册新账号。
## 线上部署(105 服务器 · w.tkmind.cn
生产目录:**`/root/wordloop`**,服务器 **`120.26.184.105`**RDS MySQL。
```bash
chmod +x deploy/publish.sh deploy/install-production.sh
./deploy/publish.sh # 默认 root@120.26.184.105
# 数据库等可写在 deploy/secrets.env(见 secrets.env.example
```
Cloudflare DNS:子域 **`w`** → A 记录 **`120.26.184.105`**(橙云代理)。Tunnel 方案见 **[deploy/DEPLOY.md](deploy/DEPLOY.md)**。
## 项目结构
```
wordloop/
├── backend/ # FastAPI 后端
├── frontend/ # Vue 3 前端
├── deploy/ # Nginx、Cloudflare Tunnel、部署说明
└── README.md
```
## 核心功能
- 用户注册 / 登录(JWT
- 中英互译(本地词典 MVP,可扩展 AI)
- 个人单词库(新词 / 学习中 / 已掌握 / 易错词)
- 每日选择题训练
- 掌握规则与复习间隔
- 学习统计与设置
+8
View File
@@ -0,0 +1,8 @@
# 复制为 .env 后按需修改
MYSQL_HOST=localhost
MYSQL_PORT=3306
MYSQL_USER=boot
MYSQL_PASSWORD=888888
MYSQL_DATABASE=wordloop
WORDLOOP_SECRET_KEY=wordloop-dev-secret-change-in-production
+56
View File
@@ -0,0 +1,56 @@
import os
from datetime import datetime, timedelta, timezone
from typing import Optional
import bcrypt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from sqlalchemy.orm import Session
from database import get_db
from models import User
SECRET_KEY = os.getenv("WORDLOOP_SECRET_KEY", "wordloop-dev-secret-change-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7
security = HTTPBearer()
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verify_password(plain: str, hashed: str) -> bool:
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
def create_access_token(user_id: int, username: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
payload = {"sub": str(user_id), "username": username, "exp": expire}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db),
) -> User:
token = credentials.credentials
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效或过期的登录凭证",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id: Optional[str] = payload.get("sub")
if user_id is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = db.query(User).filter(User.id == int(user_id)).first()
if user is None:
raise credentials_exception
return user
+33
View File
@@ -0,0 +1,33 @@
from functools import lru_cache
from urllib.parse import quote_plus
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
mysql_host: str = "localhost"
mysql_port: int = 3306
mysql_user: str = "boot"
mysql_password: str = "888888"
mysql_database: str = "wordloop"
@property
def database_url(self) -> str:
user = quote_plus(self.mysql_user)
password = quote_plus(self.mysql_password)
return (
f"mysql+pymysql://{user}:{password}"
f"@{self.mysql_host}:{self.mysql_port}/{self.mysql_database}"
"?charset=utf8mb4"
)
@lru_cache
def get_settings() -> Settings:
return Settings()
+38
View File
@@ -0,0 +1,38 @@
[
{"lemma_en": "apple", "zh": "苹果", "phonetic": "/ˈæpəl/", "example_en": "I eat an apple every day.", "example_cn": "我每天吃一个苹果。"},
{"lemma_en": "banana", "zh": "香蕉", "phonetic": "/bəˈnænə/", "example_en": "She bought a banana.", "example_cn": "她买了一根香蕉。"},
{"lemma_en": "orange", "zh": "橙子", "phonetic": "/ˈɒrɪndʒ/", "example_en": "This orange is sweet.", "example_cn": "这个橙子很甜。"},
{"lemma_en": "grape", "zh": "葡萄", "phonetic": "/ɡreɪp/", "example_en": "Grapes grow in bunches.", "example_cn": "葡萄成串生长。"},
{"lemma_en": "watermelon", "zh": "西瓜", "phonetic": "/ˈwɔːtərmelən/", "example_en": "We shared a watermelon.", "example_cn": "我们分享了一个西瓜。"},
{"lemma_en": "book", "zh": "书", "phonetic": "/bʊk/", "example_en": "I read a book before bed.", "example_cn": "我睡前读一本书。"},
{"lemma_en": "school", "zh": "学校", "phonetic": "/skuːl/", "example_en": "Children go to school.", "example_cn": "孩子们去学校。"},
{"lemma_en": "teacher", "zh": "老师", "phonetic": "/ˈtiːtʃər/", "example_en": "The teacher is kind.", "example_cn": "这位老师很和蔼。"},
{"lemma_en": "student", "zh": "学生", "phonetic": "/ˈstjuːdnt/", "example_en": "Every student needs practice.", "example_cn": "每个学生都需要练习。"},
{"lemma_en": "cat", "zh": "猫", "phonetic": "/kæt/", "example_en": "The cat is sleeping.", "example_cn": "猫在睡觉。"},
{"lemma_en": "dog", "zh": "狗", "phonetic": "/dɒɡ/", "example_en": "My dog likes to run.", "example_cn": "我的狗喜欢跑。"},
{"lemma_en": "bird", "zh": "鸟", "phonetic": "/bɜːrd/", "example_en": "A bird is singing.", "example_cn": "一只鸟在歌唱。"},
{"lemma_en": "fish", "zh": "鱼", "phonetic": "/fɪʃ/", "example_en": "We saw a fish in the pond.", "example_cn": "我们在池塘里看到一条鱼。"},
{"lemma_en": "water", "zh": "水", "phonetic": "/ˈwɔːtər/", "example_en": "Drink more water.", "example_cn": "多喝水。"},
{"lemma_en": "milk", "zh": "牛奶", "phonetic": "/mɪlk/", "example_en": "She drinks milk every morning.", "example_cn": "她每天早上喝牛奶。"},
{"lemma_en": "bread", "zh": "面包", "phonetic": "/bred/", "example_en": "Fresh bread smells good.", "example_cn": "新鲜面包闻起来很香。"},
{"lemma_en": "rice", "zh": "米饭", "phonetic": "/raɪs/", "example_en": "Rice is a staple food.", "example_cn": "米饭是主食。"},
{"lemma_en": "egg", "zh": "鸡蛋", "phonetic": "/eɡ/", "example_en": "I had an egg for breakfast.", "example_cn": "我早餐吃了一个鸡蛋。"},
{"lemma_en": "mother", "zh": "妈妈", "phonetic": "/ˈmʌðər/", "example_en": "My mother cooks well.", "example_cn": "我妈妈做饭很好。"},
{"lemma_en": "father", "zh": "爸爸", "phonetic": "/ˈfɑːðər/", "example_en": "My father works hard.", "example_cn": "我爸爸工作很努力。"},
{"lemma_en": "friend", "zh": "朋友", "phonetic": "/frend/", "example_en": "A good friend helps you.", "example_cn": "好朋友会帮助你。"},
{"lemma_en": "happy", "zh": "快乐的", "phonetic": "/ˈhæpi/", "example_en": "I feel happy today.", "example_cn": "我今天感到快乐。"},
{"lemma_en": "sad", "zh": "悲伤的", "phonetic": "/sæd/", "example_en": "Don't be sad.", "example_cn": "不要悲伤。"},
{"lemma_en": "big", "zh": "大的", "phonetic": "/bɪɡ/", "example_en": "That is a big house.", "example_cn": "那是一座大房子。"},
{"lemma_en": "small", "zh": "小的", "phonetic": "/smɔːl/", "example_en": "The box is small.", "example_cn": "这个盒子很小。"},
{"lemma_en": "red", "zh": "红色", "phonetic": "/red/", "example_en": "She wore a red dress.", "example_cn": "她穿了一条红裙子。"},
{"lemma_en": "blue", "zh": "蓝色", "phonetic": "/bluː/", "example_en": "The sky is blue.", "example_cn": "天空是蓝色的。"},
{"lemma_en": "green", "zh": "绿色", "phonetic": "/ɡriːn/", "example_en": "Grass is green.", "example_cn": "草是绿色的。"},
{"lemma_en": "run", "zh": "跑", "phonetic": "/rʌn/", "example_en": "They run every morning.", "example_cn": "他们每天早上跑步。"},
{"lemma_en": "walk", "zh": "走", "phonetic": "/wɔːk/", "example_en": "Let's walk to the park.", "example_cn": "我们走到公园去吧。"},
{"lemma_en": "read", "zh": "读", "phonetic": "/riːd/", "example_en": "I like to read stories.", "example_cn": "我喜欢读故事。"},
{"lemma_en": "write", "zh": "写", "phonetic": "/raɪt/", "example_en": "Please write your name.", "example_cn": "请写下你的名字。"},
{"lemma_en": "computer", "zh": "电脑", "phonetic": "/kəmˈpjuːtər/", "example_en": "I use a computer for work.", "example_cn": "我用电脑工作。"},
{"lemma_en": "phone", "zh": "手机", "phonetic": "/foʊn/", "example_en": "Her phone rang.", "example_cn": "她的手机响了。"},
{"lemma_en": "music", "zh": "音乐", "phonetic": "/ˈmjuːzɪk/", "example_en": "I listen to music.", "example_cn": "我听音乐。"},
{"lemma_en": "love", "zh": "爱", "phonetic": "/lʌv/", "example_en": "I love my family.", "example_cn": "我爱我的家人。"}
]
+23
View File
@@ -0,0 +1,23 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
from config import get_settings
settings = get_settings()
engine = create_engine(
settings.database_url,
pool_pre_ping=True,
pool_recycle=3600,
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+28
View File
@@ -0,0 +1,28 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from database import Base, engine
from routers import auth_router, quiz_router, settings_router, translate_router, word_router
Base.metadata.create_all(bind=engine)
app = FastAPI(title="WordLoop API", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth_router.router)
app.include_router(translate_router.router)
app.include_router(word_router.router)
app.include_router(quiz_router.router)
app.include_router(settings_router.router)
@app.get("/")
def root():
return {"message": "WordLoop API", "docs": "/docs"}
+71
View File
@@ -0,0 +1,71 @@
from sqlalchemy import Column, ForeignKey, Integer, String, Text
from database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, autoincrement=True)
username = Column(String(50), unique=True, nullable=False, index=True)
password_hash = Column(String(255), nullable=False)
created_at = Column(String(32), nullable=False)
class Word(Base):
__tablename__ = "words"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
source_text = Column(String(500), nullable=False)
target_text = Column(String(500), nullable=False)
source_lang = Column(String(8), nullable=False)
target_lang = Column(String(8), nullable=False)
phonetic = Column(String(128), nullable=True)
example_en = Column(Text, nullable=True)
example_cn = Column(Text, nullable=True)
status = Column(String(32), nullable=False, default="new")
correct_count = Column(Integer, nullable=False, default=0)
wrong_count = Column(Integer, nullable=False, default=0)
consecutive_correct_count = Column(Integer, nullable=False, default=0)
mastery_score = Column(Integer, nullable=False, default=0)
review_due_date = Column(String(32), nullable=True)
last_reviewed_at = Column(String(32), nullable=True)
created_at = Column(String(32), nullable=False)
class QuizRecord(Base):
__tablename__ = "quiz_records"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
word_id = Column(Integer, ForeignKey("words.id"), nullable=False)
question_type = Column(String(32), nullable=False)
user_answer = Column(String(500), nullable=False)
correct_answer = Column(String(500), nullable=False)
is_correct = Column(Integer, nullable=False)
created_at = Column(String(32), nullable=False)
class UserSettings(Base):
__tablename__ = "user_settings"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id"), unique=True, nullable=False)
daily_target = Column(Integer, nullable=False, default=20)
master_required_count = Column(Integer, nullable=False, default=3)
weak_wrong_threshold = Column(Integer, nullable=False, default=3)
class DictionaryEntry(Base):
"""全局离线翻译词典,与用户个人 words 表分离。"""
__tablename__ = "dictionary_entries"
id = Column(Integer, primary_key=True, autoincrement=True)
lemma_en = Column(String(128), unique=True, nullable=False, index=True)
zh = Column(String(256), nullable=False, index=True)
phonetic = Column(String(128), nullable=True)
example_en = Column(Text, nullable=True)
example_cn = Column(Text, nullable=True)
source = Column(String(64), nullable=True)
+9
View File
@@ -0,0 +1,9 @@
fastapi==0.115.6
uvicorn[standard]==0.32.1
sqlalchemy==2.0.36
bcrypt==4.2.1
python-jose[cryptography]==3.3.0
pydantic==2.10.3
pydantic-settings==2.6.1
python-multipart==0.0.17
pymysql==1.1.1
View File
+55
View File
@@ -0,0 +1,55 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from auth import create_access_token, get_current_user, hash_password, verify_password
from database import get_db
from models import User, UserSettings
from schemas import TokenResponse, UserLogin, UserOut, UserRegister
router = APIRouter(prefix="/api/auth", tags=["auth"])
def utc_now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@router.post("/register", response_model=UserOut)
def register(data: UserRegister, db: Session = Depends(get_db)):
if db.query(User).filter(User.username == data.username).first():
raise HTTPException(status_code=400, detail="用户名已存在")
user = User(
username=data.username,
password_hash=hash_password(data.password),
created_at=utc_now_iso(),
)
db.add(user)
db.flush()
settings = UserSettings(
user_id=user.id,
daily_target=20,
master_required_count=3,
weak_wrong_threshold=3,
)
db.add(settings)
db.commit()
db.refresh(user)
return user
@router.post("/login", response_model=TokenResponse)
def login(data: UserLogin, db: Session = Depends(get_db)):
user = db.query(User).filter(User.username == data.username).first()
if not user or not verify_password(data.password, user.password_hash):
raise HTTPException(status_code=401, detail="用户名或密码错误")
token = create_access_token(user.id, user.username)
return TokenResponse(access_token=token)
@router.get("/me", response_model=UserOut)
def me(current_user: User = Depends(get_current_user)):
return current_user
+49
View File
@@ -0,0 +1,49 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from auth import get_current_user
from database import get_db
from models import User
from schemas import (
DailyQuizResponse,
QuizAnswerRequest,
QuizAnswerResponse,
QuizStatsResponse,
)
from services.quiz_service import quiz_service
router = APIRouter(prefix="/api/quiz", tags=["quiz"])
@router.get("/daily", response_model=DailyQuizResponse)
def daily_quiz(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = quiz_service.get_daily_quiz(db, current_user)
return DailyQuizResponse(**result)
@router.post("/answer", response_model=QuizAnswerResponse)
def submit_answer(
data: QuizAnswerRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = quiz_service.submit_answer(
db,
current_user,
data.word_id,
data.question_type,
data.user_answer,
data.correct_answer,
)
return QuizAnswerResponse(**result)
@router.get("/stats", response_model=QuizStatsResponse)
def quiz_stats(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return quiz_service.get_stats(db, current_user)
+45
View File
@@ -0,0 +1,45 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from auth import get_current_user
from database import get_db
from models import User, UserSettings
from schemas import SettingsOut, SettingsUpdate
from services.quiz_service import quiz_service
router = APIRouter(prefix="/api/settings", tags=["settings"])
@router.get("", response_model=SettingsOut)
def get_settings(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
s = quiz_service.get_settings(db, current_user)
return SettingsOut(
daily_target=s.daily_target,
master_required_count=s.master_required_count,
weak_wrong_threshold=s.weak_wrong_threshold,
)
@router.patch("", response_model=SettingsOut)
def update_settings(
data: SettingsUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
s = quiz_service.get_settings(db, current_user)
if data.daily_target is not None:
s.daily_target = data.daily_target
if data.master_required_count is not None:
s.master_required_count = data.master_required_count
if data.weak_wrong_threshold is not None:
s.weak_wrong_threshold = data.weak_wrong_threshold
db.commit()
db.refresh(s)
return SettingsOut(
daily_target=s.daily_target,
master_required_count=s.master_required_count,
weak_wrong_threshold=s.weak_wrong_threshold,
)
+20
View File
@@ -0,0 +1,20 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from auth import get_current_user
from database import get_db
from models import User
from schemas import TranslateRequest, TranslateResponse
from services.translation_service import translation_service
router = APIRouter(prefix="/api", tags=["translate"])
@router.post("/translate", response_model=TranslateResponse)
def translate(
data: TranslateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = translation_service.translate(db, data.text)
return TranslateResponse(**result)
+60
View File
@@ -0,0 +1,60 @@
from typing import Optional
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from auth import get_current_user
from database import get_db
from models import User
from schemas import WordCreate, WordOut, WordUpdate
from services.word_service import word_service
router = APIRouter(prefix="/api/words", tags=["words"])
@router.post("", response_model=WordOut)
def create_word(
data: WordCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
word = word_service.create_word(db, current_user, data.model_dump())
return word
@router.get("", response_model=list[WordOut])
def list_words(
status: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return word_service.list_words(db, current_user, status)
@router.get("/{word_id}", response_model=WordOut)
def get_word(
word_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return word_service.get_word(db, current_user, word_id)
@router.delete("/{word_id}")
def delete_word(
word_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
word_service.delete_word(db, current_user, word_id)
return {"ok": True}
@router.patch("/{word_id}", response_model=WordOut)
def update_word(
word_id: int,
data: WordUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return word_service.update_word(db, current_user, word_id, data.model_dump(exclude_unset=True))
+142
View File
@@ -0,0 +1,142 @@
from typing import Optional
from pydantic import BaseModel, Field
# Auth
class UserRegister(BaseModel):
username: str = Field(min_length=2, max_length=50)
password: str = Field(min_length=6, max_length=100)
class UserLogin(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class UserOut(BaseModel):
id: int
username: str
created_at: str
class Config:
from_attributes = True
# Translate
class TranslateRequest(BaseModel):
text: str = Field(min_length=1)
class TranslateResponse(BaseModel):
source_text: str
target_text: str
source_lang: str
target_lang: str
phonetic: Optional[str] = None
example_en: Optional[str] = None
example_cn: Optional[str] = None
# Words
class WordCreate(BaseModel):
source_text: str
target_text: str
source_lang: str
target_lang: str
phonetic: Optional[str] = None
example_en: Optional[str] = None
example_cn: Optional[str] = None
class WordUpdate(BaseModel):
status: Optional[str] = None
class WordOut(BaseModel):
id: int
user_id: int
source_text: str
target_text: str
source_lang: str
target_lang: str
phonetic: Optional[str] = None
example_en: Optional[str] = None
example_cn: Optional[str] = None
status: str
correct_count: int
wrong_count: int
consecutive_correct_count: int
mastery_score: int
review_due_date: Optional[str] = None
last_reviewed_at: Optional[str] = None
created_at: str
class Config:
from_attributes = True
# Quiz
class QuizOption(BaseModel):
label: str
text: str
class QuizQuestion(BaseModel):
word_id: int
question_type: str
prompt: str
options: list[QuizOption]
correct_answer: str
class DailyQuizResponse(BaseModel):
questions: list[QuizQuestion]
total: int
class QuizAnswerRequest(BaseModel):
word_id: int
question_type: str
user_answer: str
correct_answer: str
class QuizAnswerResponse(BaseModel):
is_correct: bool
correct_answer: str
word: WordOut
class QuizStatsResponse(BaseModel):
total_words: int
new_count: int
learning_count: int
mastered_count: int
weak_count: int
today_quiz_count: int
today_correct_count: int
today_accuracy: float
daily_target: int
today_completed: int
streak_days: int
# Settings
class SettingsOut(BaseModel):
daily_target: int
master_required_count: int
weak_wrong_threshold: int
class Config:
from_attributes = True
class SettingsUpdate(BaseModel):
daily_target: Optional[int] = Field(None, ge=1, le=100)
master_required_count: Optional[int] = Field(None, ge=1, le=20)
weak_wrong_threshold: Optional[int] = Field(None, ge=1, le=20)
View File
+83
View File
@@ -0,0 +1,83 @@
"""词典导入共享工具。"""
from __future__ import annotations
import re
from typing import Optional
from sqlalchemy.dialects.mysql import insert as mysql_insert
from sqlalchemy.orm import Session
from models import DictionaryEntry
_POS_PREFIX = re.compile(r"^[a-zA-Z]+\.\s*")
_TAG_PREFIX = re.compile(r"^\[[^\]]+\]\s*")
def clean_translation(text: str, max_len: int = 256) -> str:
if not text:
return ""
lines = [ln.strip() for ln in text.replace("\r", "").split("\n") if ln.strip()]
parts: list[str] = []
for ln in lines:
ln = _TAG_PREFIX.sub("", ln)
ln = _POS_PREFIX.sub("", ln)
if ln:
parts.append(ln)
result = "".join(parts) if parts else text.strip()
return result[:max_len]
def normalize_row(raw: dict, source: Optional[str]) -> Optional[dict]:
lemma_en = (raw.get("lemma_en") or raw.get("en") or raw.get("word") or "").strip().lower()
zh = clean_translation(raw.get("zh") or raw.get("cn") or raw.get("translation") or "")
if not lemma_en or not zh:
return None
if len(lemma_en) > 128:
return None
phonetic = (raw.get("phonetic") or "").strip() or None
if phonetic and len(phonetic) > 128:
phonetic = phonetic[:128]
return {
"lemma_en": lemma_en,
"zh": zh,
"phonetic": phonetic,
"example_en": (raw.get("example_en") or "").strip() or None,
"example_cn": (raw.get("example_cn") or "").strip() or None,
"source": source or (raw.get("source") or "import"),
}
def upsert_batch(session: Session, rows: list[dict]) -> int:
if not rows:
return 0
stmt = mysql_insert(DictionaryEntry).values(rows)
stmt = stmt.on_duplicate_key_update(
zh=stmt.inserted.zh,
phonetic=stmt.inserted.phonetic,
example_en=stmt.inserted.example_en,
example_cn=stmt.inserted.example_cn,
source=stmt.inserted.source,
)
session.execute(stmt)
return len(rows)
def upsert_entries(session: Session, raw_rows: list[dict], source: Optional[str], batch_size: int = 2000) -> int:
batch: list[dict] = []
total = 0
for raw in raw_rows:
row = normalize_row(raw, source)
if not row:
continue
batch.append(row)
if len(batch) >= batch_size:
upsert_batch(session, batch)
session.commit()
total += len(batch)
batch.clear()
if batch:
upsert_batch(session, batch)
session.commit()
total += len(batch)
return total
+83
View File
@@ -0,0 +1,83 @@
"""离线翻译词典导入脚本。
用法(在 backend 目录下):
python -m scripts.import_dictionary
python -m scripts.import_dictionary --file data/dictionary.json
python -m scripts.import_dictionary --file /path/to/words.csv
python -m scripts.import_dictionary --file data/dictionary.json --source seed
"""
from __future__ import annotations
import argparse
import csv
import json
import sys
from pathlib import Path
from typing import Optional
BACKEND_DIR = Path(__file__).resolve().parent.parent
DEFAULT_FILE = BACKEND_DIR / "data" / "dictionary.json"
sys.path.insert(0, str(BACKEND_DIR))
from database import SessionLocal # noqa: E402
from scripts.dictionary_importer import upsert_entries # noqa: E402
def load_json(path: Path) -> list[dict]:
data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, list):
return data
if isinstance(data, dict) and "entries" in data:
return data["entries"]
raise ValueError(f"无法解析 JSON 格式: {path}")
def load_csv(path: Path) -> list[dict]:
with path.open(encoding="utf-8-sig", newline="") as f:
reader = csv.DictReader(f)
return list(reader)
def load_entries(path: Path) -> list[dict]:
suffix = path.suffix.lower()
if suffix == ".json":
return load_json(path)
if suffix == ".csv":
return load_csv(path)
raise ValueError(f"不支持的文件格式: {suffix},请使用 .json 或 .csv")
def main() -> None:
parser = argparse.ArgumentParser(description="导入离线翻译词典到 dictionary_entries 表")
parser.add_argument(
"--file",
type=Path,
default=DEFAULT_FILE,
help=f"JSON 或 CSV 文件路径(默认: {DEFAULT_FILE.name}",
)
parser.add_argument(
"--source",
type=str,
default=None,
help="数据来源标记,如 seed / ecdict / custom",
)
args = parser.parse_args()
path = args.file if args.file.is_absolute() else BACKEND_DIR / args.file
if not path.exists():
print(f"错误: 文件不存在 {path}", file=sys.stderr)
sys.exit(1)
raw_rows = load_entries(path)
session = SessionLocal()
try:
total = upsert_entries(session, raw_rows, args.source)
finally:
session.close()
print(f"导入完成: 文件={path.name}, 处理={total}")
if __name__ == "__main__":
main()
+179
View File
@@ -0,0 +1,179 @@
"""从线上 ECDICT 词库下载并导入 MySQL。
数据源: https://github.com/skywind3000/ECDICT (开源英汉词典,约 340 万词条)
用法(在 backend 目录下):
python -m scripts.import_dictionary_online
python -m scripts.import_dictionary_online --preset full
python -m scripts.import_dictionary_online --preset standard --skip-download
"""
from __future__ import annotations
import argparse
import sqlite3
import sys
import zipfile
from pathlib import Path
from typing import Optional
from urllib.request import urlretrieve
BACKEND_DIR = Path(__file__).resolve().parent.parent
CACHE_DIR = BACKEND_DIR / "data" / "cache"
ECDICT_ZIP_URL = (
"https://github.com/skywind3000/ECDICT/releases/download/1.0.28/ecdict-sqlite-28.zip"
)
ECDICT_ZIP_PATH = CACHE_DIR / "ecdict-sqlite-28.zip"
ECDICT_DB_PATH = CACHE_DIR / "stardict.db"
sys.path.insert(0, str(BACKEND_DIR))
from database import SessionLocal # noqa: E402
from scripts.dictionary_importer import upsert_entries # noqa: E402
PRESET_SQL = {
# 常用词:Collins / 牛津核心词,或词频排名前 5 万
"standard": """
translation IS NOT NULL AND translation != ''
AND (
collins >= 1 OR oxford = 1
OR (frq IS NOT NULL AND frq <= 50000)
)
""",
# 核心词:Collins 星级或牛津 3000
"core": """
translation IS NOT NULL AND translation != ''
AND (collins >= 1 OR oxford = 1)
""",
# 全量(约 338 万,耗时较长)
"full": """
translation IS NOT NULL AND translation != ''
""",
}
def download(url: str, dest: Path) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
print(f"正在下载 ECDICT SQLite(约 207MB)…")
print(f" {url}")
def progress(block_num: int, block_size: int, total_size: int) -> None:
if total_size <= 0:
return
done = block_num * block_size
pct = min(100, done * 100 // total_size)
mb = done / 1024 / 1024
total_mb = total_size / 1024 / 1024
print(f"\r 进度: {pct:3d}% ({mb:.1f}/{total_mb:.1f} MB)", end="", flush=True)
urlretrieve(url, dest, reporthook=progress)
print()
def ensure_ecdict_db(skip_download: bool) -> Path:
if ECDICT_DB_PATH.exists():
return ECDICT_DB_PATH
if not ECDICT_ZIP_PATH.exists():
if skip_download:
print(f"错误: 未找到本地词库,请先下载或去掉 --skip-download", file=sys.stderr)
sys.exit(1)
download(ECDICT_ZIP_URL, ECDICT_ZIP_PATH)
print(f"正在解压 {ECDICT_ZIP_PATH.name}")
CACHE_DIR.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(ECDICT_ZIP_PATH, "r") as zf:
zf.extract("stardict.db", CACHE_DIR)
print(f"词库就绪: {ECDICT_DB_PATH}")
return ECDICT_DB_PATH
def count_entries(db_path: Path, where: str) -> int:
conn = sqlite3.connect(db_path)
try:
cur = conn.execute(f"SELECT COUNT(*) FROM stardict WHERE {where}")
return int(cur.fetchone()[0])
finally:
conn.close()
def iter_ecdict_rows(db_path: Path, where: str, limit: Optional[int]):
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
try:
sql = f"""
SELECT word, phonetic, translation
FROM stardict
WHERE {where}
ORDER BY COALESCE(frq, 9999999), word
"""
if limit:
sql += f" LIMIT {int(limit)}"
cur = conn.execute(sql)
while True:
rows = cur.fetchmany(5000)
if not rows:
break
for row in rows:
yield {
"word": row["word"],
"phonetic": row["phonetic"],
"translation": row["translation"],
}
finally:
conn.close()
def import_from_ecdict(
db_path: Path,
preset: str,
limit: Optional[int],
batch_size: int,
) -> int:
where = PRESET_SQL[preset]
total_expected = count_entries(db_path, where)
if limit:
total_expected = min(total_expected, limit)
print(f"预设: {preset},预计导入 {total_expected:,}")
session = SessionLocal()
imported = 0
buffer: list[dict] = []
try:
for row in iter_ecdict_rows(db_path, where, limit):
buffer.append(row)
if len(buffer) >= batch_size:
imported += upsert_entries(session, buffer, source="ecdict", batch_size=batch_size)
buffer.clear()
print(f"\r 已导入 {imported:,} / {total_expected:,}", end="", flush=True)
if buffer:
imported += upsert_entries(session, buffer, source="ecdict", batch_size=batch_size)
print()
except Exception:
session.rollback()
raise
finally:
session.close()
return imported
def main() -> None:
parser = argparse.ArgumentParser(description="从线上 ECDICT 下载并导入离线翻译词典")
parser.add_argument(
"--preset",
choices=list(PRESET_SQL.keys()),
default="standard",
help="standard=常用约 83 万 | core=核心约 1.4 万 | full=全量约 338 万(默认 standard",
)
parser.add_argument("--limit", type=int, default=None, help="最多导入条数(调试用)")
parser.add_argument("--batch-size", type=int, default=2000, help="MySQL 批量写入大小")
parser.add_argument("--skip-download", action="store_true", help="跳过下载,使用本地 cache")
args = parser.parse_args()
db_path = ensure_ecdict_db(args.skip_download)
imported = import_from_ecdict(db_path, args.preset, args.limit, args.batch_size)
print(f"导入完成: {imported:,} 条(source=ecdict, preset={args.preset}")
if __name__ == "__main__":
main()
+4
View File
@@ -0,0 +1,4 @@
-- 使用有建库权限的账号执行,例如:mysql -u boot -p < scripts/init_mysql.sql
CREATE DATABASE IF NOT EXISTS wordloop
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
View File
+55
View File
@@ -0,0 +1,55 @@
import random
from typing import Optional
from sqlalchemy.orm import Session
from models import DictionaryEntry
class DictionaryService:
def lookup_en(self, db: Session, text: str) -> Optional[DictionaryEntry]:
key = text.lower().strip()
if not key:
return None
return db.query(DictionaryEntry).filter(DictionaryEntry.lemma_en == key).first()
def lookup_zh(self, db: Session, text: str) -> Optional[DictionaryEntry]:
text = text.strip()
if not text:
return None
entry = db.query(DictionaryEntry).filter(DictionaryEntry.zh == text).first()
if entry:
return entry
return (
db.query(DictionaryEntry)
.filter(DictionaryEntry.zh.contains(text))
.first()
)
def random_zh_values(self, db: Session, exclude: str, limit: int) -> list[str]:
rows = (
db.query(DictionaryEntry.zh)
.filter(DictionaryEntry.zh != exclude)
.order_by(DictionaryEntry.id)
.all()
)
values = [r[0] for r in rows]
random.shuffle(values)
return values[:limit]
def random_en_lemmas(self, db: Session, exclude: str, limit: int) -> list[str]:
rows = (
db.query(DictionaryEntry.lemma_en)
.filter(DictionaryEntry.lemma_en != exclude.lower())
.order_by(DictionaryEntry.id)
.all()
)
values = [r[0] for r in rows]
random.shuffle(values)
return values[:limit]
def count(self, db: Session) -> int:
return db.query(DictionaryEntry).count()
dictionary_service = DictionaryService()
+269
View File
@@ -0,0 +1,269 @@
import random
from datetime import datetime, timedelta, timezone
from typing import Optional
from sqlalchemy import func
from sqlalchemy.orm import Session
from models import QuizRecord, User, UserSettings, Word
from schemas import QuizOption, QuizQuestion, QuizStatsResponse, WordOut
from services.dictionary_service import dictionary_service
from services.word_service import calc_mastery_score, utc_now_iso
def today_str() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
def review_interval_days(consecutive: int) -> int:
"""根据连续答对次数计算下次复习间隔(天)。"""
if consecutive <= 0:
return 0
if consecutive == 1:
return 1
if consecutive == 2:
return 3
if consecutive == 3:
return 7
if consecutive >= 5:
return 15
return 7
class QuizService:
def get_settings(self, db: Session, user: User) -> UserSettings:
settings = db.query(UserSettings).filter(UserSettings.user_id == user.id).first()
if not settings:
settings = UserSettings(user_id=user.id)
db.add(settings)
db.commit()
db.refresh(settings)
return settings
def select_daily_words(self, db: Session, user: User, limit: int) -> list[Word]:
today = today_str()
all_words = db.query(Word).filter(Word.user_id == user.id).all()
weak = [w for w in all_words if w.status == "weak"]
learning = [w for w in all_words if w.status == "learning"]
new = [w for w in all_words if w.status == "new"]
mastered_due = [
w
for w in all_words
if w.status == "mastered"
and w.review_due_date
and w.review_due_date <= today
]
pool: list[Word] = []
for group in (weak, learning, new, mastered_due):
random.shuffle(group)
for w in group:
if w not in pool:
pool.append(w)
if len(pool) >= limit:
return pool[:limit]
return pool[:limit]
def build_question(self, db: Session, word: Word, all_words: list[Word]) -> QuizQuestion:
question_type = random.choice(["en_to_zh", "zh_to_en"])
if question_type == "en_to_zh":
en = word.target_text if word.source_lang == "zh" else word.source_text
zh = word.source_text if word.source_lang == "zh" else word.target_text
prompt = en
correct = zh
distractor_pool = [
(w.source_text if w.source_lang == "zh" else w.target_text)
for w in all_words
if w.id != word.id
]
else:
zh = word.source_text if word.source_lang == "zh" else word.target_text
en = word.target_text if word.source_lang == "zh" else word.source_text
prompt = zh
correct = en
distractor_pool = [
(w.target_text if w.source_lang == "zh" else w.source_text)
for w in all_words
if w.id != word.id
]
distractors = list({d for d in distractor_pool if d != correct})
random.shuffle(distractors)
if len(distractors) < 3:
if question_type == "en_to_zh":
extra = dictionary_service.random_zh_values(db, correct, 10)
else:
extra = dictionary_service.random_en_lemmas(db, correct, 10)
for e in extra:
if e not in distractors:
distractors.append(e)
if len(distractors) >= 3:
break
options_text = [correct] + distractors[:3]
random.shuffle(options_text)
labels = ["A", "B", "C", "D"]
options = [
QuizOption(label=labels[i], text=options_text[i])
for i in range(min(4, len(options_text)))
]
return QuizQuestion(
word_id=word.id,
question_type=question_type,
prompt=prompt,
options=options,
correct_answer=correct,
)
def get_daily_quiz(self, db: Session, user: User) -> dict:
settings = self.get_settings(db, user)
words = self.select_daily_words(db, user, settings.daily_target)
all_words = db.query(Word).filter(Word.user_id == user.id).all()
if len(all_words) < 4:
questions = []
if words:
questions = [self.build_question(db, words[0], all_words)]
else:
questions = [self.build_question(db, w, all_words) for w in words]
return {
"questions": questions,
"total": len(questions),
}
def submit_answer(
self,
db: Session,
user: User,
word_id: int,
question_type: str,
user_answer: str,
correct_answer: str,
) -> dict:
word = db.query(Word).filter(Word.id == word_id, Word.user_id == user.id).first()
if not word:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="单词不存在")
settings = self.get_settings(db, user)
is_correct = user_answer.strip() == correct_answer.strip()
now = utc_now_iso()
today = today_str()
if is_correct:
word.correct_count += 1
word.consecutive_correct_count += 1
if word.status == "new":
word.status = "learning"
if word.consecutive_correct_count >= settings.master_required_count:
word.status = "mastered"
days = review_interval_days(word.consecutive_correct_count)
due = datetime.now(timezone.utc) + timedelta(days=days)
word.review_due_date = due.strftime("%Y-%m-%d")
else:
word.wrong_count += 1
word.consecutive_correct_count = 0
if word.wrong_count >= settings.weak_wrong_threshold:
word.status = "weak"
elif word.status == "new":
word.status = "learning"
word.review_due_date = today
word.mastery_score = calc_mastery_score(word.correct_count, word.wrong_count)
word.last_reviewed_at = now
record = QuizRecord(
user_id=user.id,
word_id=word.id,
question_type=question_type,
user_answer=user_answer,
correct_answer=correct_answer,
is_correct=1 if is_correct else 0,
created_at=now,
)
db.add(record)
db.commit()
db.refresh(word)
return {
"is_correct": is_correct,
"correct_answer": correct_answer,
"word": WordOut.model_validate(word),
}
def get_stats(self, db: Session, user: User) -> QuizStatsResponse:
settings = self.get_settings(db, user)
today = today_str()
total = db.query(Word).filter(Word.user_id == user.id).count()
new_count = db.query(Word).filter(Word.user_id == user.id, Word.status == "new").count()
learning_count = (
db.query(Word).filter(Word.user_id == user.id, Word.status == "learning").count()
)
mastered_count = (
db.query(Word).filter(Word.user_id == user.id, Word.status == "mastered").count()
)
weak_count = db.query(Word).filter(Word.user_id == user.id, Word.status == "weak").count()
today_records = (
db.query(QuizRecord)
.filter(
QuizRecord.user_id == user.id,
func.date(QuizRecord.created_at) == today,
)
.all()
)
# SQLite date compare fallback: filter by prefix
if not today_records:
today_records = [
r
for r in db.query(QuizRecord).filter(QuizRecord.user_id == user.id).all()
if r.created_at.startswith(today)
]
today_quiz_count = len(today_records)
today_correct = sum(1 for r in today_records if r.is_correct == 1)
accuracy = (
round(today_correct / today_quiz_count * 100, 1) if today_quiz_count > 0 else 0.0
)
streak = self._calc_streak(db, user)
return QuizStatsResponse(
total_words=total,
new_count=new_count,
learning_count=learning_count,
mastered_count=mastered_count,
weak_count=weak_count,
today_quiz_count=today_quiz_count,
today_correct_count=today_correct,
today_accuracy=accuracy,
daily_target=settings.daily_target,
today_completed=today_quiz_count,
streak_days=streak,
)
def _calc_streak(self, db: Session, user: User) -> int:
records = (
db.query(QuizRecord)
.filter(QuizRecord.user_id == user.id)
.order_by(QuizRecord.created_at.desc())
.all()
)
days_with_quiz = set()
for r in records:
days_with_quiz.add(r.created_at[:10])
streak = 0
d = datetime.now(timezone.utc).date()
while d.isoformat() in days_with_quiz:
streak += 1
d -= timedelta(days=1)
return streak
quiz_service = QuizService()
+85
View File
@@ -0,0 +1,85 @@
import re
from typing import Optional
from sqlalchemy.orm import Session
from services.dictionary_service import dictionary_service
def contains_chinese(text: str) -> bool:
return bool(re.search(r"[\u4e00-\u9fff]", text))
class TranslationService:
"""翻译服务:优先查离线词典表,未命中时返回占位结果(暂不接入外部 API)。"""
def translate(self, db: Session, text: str) -> dict:
text = text.strip()
if not text:
raise ValueError("输入不能为空")
if contains_chinese(text):
return self._zh_to_en(db, text)
return self._en_to_zh(db, text)
def _en_to_zh(self, db: Session, text: str) -> dict:
entry = dictionary_service.lookup_en(db, text)
if entry:
return self._build_response(
text,
entry.zh,
"en",
"zh",
entry.phonetic,
entry.example_en,
entry.example_cn,
)
return self._placeholder(text, text, "en", "zh")
def _zh_to_en(self, db: Session, text: str) -> dict:
entry = dictionary_service.lookup_zh(db, text)
if entry:
return self._build_response(
text,
entry.lemma_en,
"zh",
"en",
entry.phonetic,
entry.example_en,
entry.example_cn,
)
return self._placeholder(text, f"[{text}]", "zh", "en")
def _build_response(
self,
source: str,
target: str,
source_lang: str,
target_lang: str,
phonetic: Optional[str],
example_en: Optional[str],
example_cn: Optional[str],
) -> dict:
return {
"source_text": source,
"target_text": target,
"source_lang": source_lang,
"target_lang": target_lang,
"phonetic": phonetic,
"example_en": example_en,
"example_cn": example_cn,
}
def _placeholder(self, source: str, target: str, source_lang: str, target_lang: str) -> dict:
return {
"source_text": source,
"target_text": target,
"source_lang": source_lang,
"target_lang": target_lang,
"phonetic": None,
"example_en": f"Example with {target}." if target_lang == "en" else None,
"example_cn": f"包含「{source}」的例句。" if source_lang == "zh" else None,
}
translation_service = TranslationService()
+83
View File
@@ -0,0 +1,83 @@
from datetime import datetime, timezone
from typing import Optional
from fastapi import HTTPException
from sqlalchemy.orm import Session
from models import Word, User
def utc_now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def calc_mastery_score(correct: int, wrong: int) -> int:
total = correct + wrong
if total == 0:
return 0
return round(correct / total * 100)
class WordService:
def create_word(self, db: Session, user: User, data: dict) -> Word:
existing = (
db.query(Word)
.filter(
Word.user_id == user.id,
Word.source_text == data["source_text"],
Word.target_text == data["target_text"],
)
.first()
)
if existing:
raise HTTPException(status_code=400, detail="该单词已存在")
word = Word(
user_id=user.id,
source_text=data["source_text"],
target_text=data["target_text"],
source_lang=data["source_lang"],
target_lang=data["target_lang"],
phonetic=data.get("phonetic"),
example_en=data.get("example_en"),
example_cn=data.get("example_cn"),
status="new",
correct_count=0,
wrong_count=0,
consecutive_correct_count=0,
mastery_score=0,
review_due_date=datetime.now(timezone.utc).strftime("%Y-%m-%d"),
created_at=utc_now_iso(),
)
db.add(word)
db.commit()
db.refresh(word)
return word
def list_words(self, db: Session, user: User, status: Optional[str] = None) -> list[Word]:
q = db.query(Word).filter(Word.user_id == user.id)
if status:
q = q.filter(Word.status == status)
return q.order_by(Word.created_at.desc()).all()
def get_word(self, db: Session, user: User, word_id: int) -> Word:
word = db.query(Word).filter(Word.id == word_id, Word.user_id == user.id).first()
if not word:
raise HTTPException(status_code=404, detail="单词不存在")
return word
def delete_word(self, db: Session, user: User, word_id: int) -> None:
word = self.get_word(db, user, word_id)
db.delete(word)
db.commit()
def update_word(self, db: Session, user: User, word_id: int, data: dict) -> Word:
word = self.get_word(db, user, word_id)
if "status" in data and data["status"]:
word.status = data["status"]
db.commit()
db.refresh(word)
return word
word_service = WordService()
+124
View File
@@ -0,0 +1,124 @@
# WordLoop 部署到 Cloudflare · w.tkmind.cn
本指南把 **WordLoop** 通过 **Cloudflare** 暴露为 `https://w.tkmind.cn`。应用为 Vue 前端 + FastAPI 后端 + MySQL,推荐 **Cloudflare Tunnel**(无需公网 IP、自动 HTTPS)。
---
## 一、注册 Cloudflare 并接入域名
1. 打开 [https://dash.cloudflare.com/sign-up](https://dash.cloudflare.com/sign-up) 注册账号(免费计划即可)。
2. **添加站点** → 输入根域名 **`tkmind.cn`**(子域 `w` 在根域下配置即可)。
3. Cloudflare 会给出两条 **NS 记录**,到 **购买/解析 tkmind.cn 的注册商**(阿里云、腾讯云、GoDaddy 等)把域名的 DNS 服务器改为 Cloudflare 提供的 NS。
4. 等待状态变为 **Active**(通常几分钟到 48 小时)。
> 若 `tkmind.cn` 已在 Cloudflare,跳过 2–3,直接进入第二节。
---
## 二、两种接入方式(二选一)
### 方式 ACloudflare Tunnel(推荐)
适合:家庭宽带、无固定公网 IP、或不想开放 80/443 端口。
| 步骤 | 操作 |
|------|------|
| 1 | 在**运行 WordLoop 的机器**安装 [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) |
| 2 | `cloudflared tunnel login`(浏览器授权) |
| 3 | `cloudflared tunnel create wordloop` |
| 4 | 复制 `deploy/cloudflared/config.yml.example``config.yml`,填入 `credentials-file` 实际路径 |
| 5 | `cloudflared tunnel route dns wordloop w.tkmind.cn` |
| 6 | 本机 Nginx 按 `deploy/nginx.conf` 监听 `127.0.0.1:8080`Tunnel 指向该地址 |
| 7 | `cloudflared tunnel --config deploy/cloudflared/config.yml run` 测试;稳定后用 `deploy/systemd/cloudflared-wordloop.service` |
**DNS 结果**`w.tkmind.cn` → CNAME → `xxxx.cfargotunnel.com`(由 `tunnel route dns` 自动创建)。
### 方式 B:DNS 代理到自有服务器
适合:已有云服务器与公网 IP。
在 Cloudflare **DNS****记录** 添加:
| 类型 | 名称 | 内容 | 代理 |
|------|------|------|------|
| `A` | `w` | 服务器公网 IP | 已代理(橙色云) |
服务器上:Nginx`deploy/nginx.conf`+ 后端 systemd`deploy/systemd/wordloop-backend.service`),**SSL 由 Cloudflare 边缘终止**(源站可只开 80 或由 Tunnel/内网访问)。
**SSL/TLS** 建议:**完全(严格)** 需在源站配置证书;起步可用 **灵活**(仅访客到 Cloudflare 为 HTTPS)。
---
## 三、服务器部署清单
```bash
# 1. 同步代码到例如 /var/www/wordloop
# 2. 后端
cd /var/www/wordloop/backend
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # 生产务必修改 MYSQL_* 与 WORDLOOP_SECRET_KEY
# 3. MySQL 建库(与 README 一致)
mysql -h ... -u ... -p -e "CREATE DATABASE IF NOT EXISTS wordloop ..."
# 4. 构建前端
cd /var/www/wordloop && chmod +x deploy/build.sh && ./deploy/build.sh
# 5. Nginx
sudo cp deploy/nginx.conf /etc/nginx/sites-available/wordloop
# 修改 root 路径为实际 dist 目录
sudo ln -sf /etc/nginx/sites-available/wordloop /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
# 6. 后端服务
sudo cp deploy/systemd/wordloop-backend.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now wordloop-backend
```
---
## 四、生产环境变量
`backend/.env` 示例:
```env
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_USER=wordloop
MYSQL_PASSWORD=<强密码>
MYSQL_DATABASE=wordloop
WORDLOOP_SECRET_KEY=<随机长字符串>
```
前端生产构建使用相对路径 `/api`,与 `deploy/nginx.conf` 反代一致,**无需改前端代码**。
---
## 五、验证
1. `curl -I https://w.tkmind.cn` 应返回 200。
2. 打开 `https://w.tkmind.cn` 能注册/登录。
3. API`https://w.tkmind.cn/api/`(或通过页面功能间接验证)。
4. 可选:`https://w.tkmind.cn/docs`FastAPI 文档,生产可关)。
---
## 六、常见问题
| 现象 | 处理 |
|------|------|
| DNS 未生效 | 确认 NS 已指向 Cloudflare`dig w.tkmind.cn` |
| 522 / 连接失败 | Tunnel 未运行或 Nginx/后端未监听 8080 / 18004 |
| API 401 / CORS | 同源访问应走 `/api`;勿把 API 指到另一域名 |
| 仅子域在 CF | 也可只把 `w` 用 CNAME 到 Tunnel,根域 NS 可仍在原注册商(需支持 CNAME 到 cfargotunnel.com |
---
## 本仓库相关文件
- `deploy/nginx.conf` — 静态站 + `/api` 反代
- `deploy/cloudflared/config.yml.example` — Tunnel 配置模板
- `deploy/systemd/*.service` — 后端与 Tunnel 开机自启
- `deploy/build.sh` — 前端生产构建
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# 在项目根目录执行:./deploy/build.sh
set -e
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
echo ">>> 构建前端..."
cd "$ROOT/frontend"
npm ci
npm run build
echo ">>> 完成。静态文件在 frontend/dist/"
echo " 生产部署请将 dist 同步到服务器,例如:"
echo " rsync -avz frontend/dist/ user@server:/var/www/wordloop/frontend/dist/"
+15
View File
@@ -0,0 +1,15 @@
# Cloudflare Tunnel 示例配置
# 1. 复制为 config.ymlcp config.yml.example config.yml
# 2. 运行 cloudflared tunnel login 后创建隧道:
# cloudflared tunnel create wordloop
# 3. 将 credentials-file 路径改为实际 JSON(通常在 ~/.cloudflared/<tunnel-id>.json
# 4. 在 Cloudflare 控制台为 w.tkmind.cn 添加 Public Hostname,或执行:
# cloudflared tunnel route dns wordloop w.tkmind.cn
tunnel: wordloop
credentials-file: /path/to/.cloudflared/<TUNNEL-UUID>.json
ingress:
- hostname: w.tkmind.cn
service: http://127.0.0.1:8080
- service: http_status:404
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env bash
# 在 105 服务器上执行(/root/wordloop
set -euo pipefail
ROOT="${WORDLOOP_ROOT:-/root/wordloop}"
cd "$ROOT"
MYSQL_HOST="${MYSQL_HOST:?MYSQL_HOST required}"
MYSQL_PORT="${MYSQL_PORT:-3306}"
MYSQL_USER="${MYSQL_USER:?MYSQL_USER required}"
MYSQL_PASSWORD="${MYSQL_PASSWORD:?MYSQL_PASSWORD required}"
MYSQL_DATABASE="${MYSQL_DATABASE:-wordloop}"
WORDLOOP_SECRET_KEY="${WORDLOOP_SECRET_KEY:-$(openssl rand -hex 32)}"
echo ">>> 写入 backend/.env"
cat > "$ROOT/backend/.env" <<EOF
MYSQL_HOST=${MYSQL_HOST}
MYSQL_PORT=${MYSQL_PORT}
MYSQL_USER=${MYSQL_USER}
MYSQL_PASSWORD=${MYSQL_PASSWORD}
MYSQL_DATABASE=${MYSQL_DATABASE}
WORDLOOP_SECRET_KEY=${WORDLOOP_SECRET_KEY}
EOF
chmod 600 "$ROOT/backend/.env"
echo ">>> Python 虚拟环境与依赖"
cd "$ROOT/backend"
if [ ! -d venv ]; then
python3 -m venv venv
fi
source venv/bin/activate
pip install -q -r requirements.txt
echo ">>> 创建数据库(若不存在)并建表"
python - <<'PY'
import pymysql
from config import get_settings
s = get_settings()
conn = pymysql.connect(
host=s.mysql_host,
port=s.mysql_port,
user=s.mysql_user,
password=s.mysql_password,
charset="utf8mb4",
)
try:
with conn.cursor() as cur:
cur.execute(
f"CREATE DATABASE IF NOT EXISTS `{s.mysql_database}` "
"CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
)
conn.commit()
print(f"database `{s.mysql_database}` ready")
finally:
conn.close()
from database import engine, Base
from sqlalchemy import text
import models # noqa: F401
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
Base.metadata.create_all(bind=engine)
print("tables ready")
PY
echo ">>> 构建前端"
cd "$ROOT/frontend"
if ! command -v npm >/dev/null 2>&1; then
echo "请先安装 Node.js/npm"
exit 1
fi
npm ci --silent
npm run build
echo ">>> Nginx 可读静态目录"
chmod 755 /root /root/wordloop /root/wordloop/frontend /root/wordloop/frontend/dist 2>/dev/null || true
echo ">>> 配置 Nginx + HTTPS (w.tkmind.cn)"
if command -v nginx >/dev/null 2>&1; then
cp "$ROOT/deploy/wordloop-locations.inc" /etc/nginx/conf.d/wordloop-locations.inc
chmod +x "$ROOT/deploy/ssl-w.tkmind.cn.sh"
bash "$ROOT/deploy/ssl-w.tkmind.cn.sh"
systemctl enable nginx 2>/dev/null || true
else
echo "警告: 未安装 nginx,请手动安装后执行 deploy/ssl-w.tkmind.cn.sh"
fi
echo ">>> 配置 systemd 后端服务"
cp "$ROOT/deploy/systemd/wordloop-backend-root.service" /etc/systemd/system/wordloop-backend.service
systemctl daemon-reload
systemctl enable wordloop-backend
systemctl restart wordloop-backend
echo ">>> 完成"
systemctl --no-pager status wordloop-backend || true
curl -sf http://127.0.0.1:18004/ >/dev/null && echo "API: ok" || echo "API: 请检查 journalctl -u wordloop-backend"
+28
View File
@@ -0,0 +1,28 @@
# WordLoop 生产 — w.tkmind.cn80 + 443
server {
listen 80;
server_name w.tkmind.cn;
root /root/wordloop/frontend/dist;
index index.html;
client_max_body_size 10m;
include /etc/nginx/conf.d/wordloop-locations.inc;
}
server {
listen 443 ssl http2;
server_name w.tkmind.cn;
ssl_certificate /etc/letsencrypt/live/w.tkmind.cn/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/w.tkmind.cn/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
root /root/wordloop/frontend/dist;
index index.html;
client_max_body_size 10m;
include /etc/nginx/conf.d/wordloop-locations.inc;
}
+37
View File
@@ -0,0 +1,37 @@
# WordLoop 生产环境 Nginx(监听本机 8080,由 cloudflared 或上游反代转发)
# 安装:sudo cp deploy/nginx.conf /etc/nginx/sites-available/wordloop
# sudo ln -sf /etc/nginx/sites-available/wordloop /etc/nginx/sites-enabled/
# sudo nginx -t && sudo systemctl reload nginx
server {
listen 127.0.0.1:8080;
server_name w.tkmind.cn;
root /var/www/wordloop/frontend/dist;
index index.html;
client_max_body_size 10m;
location /api/ {
proxy_pass http://127.0.0.1:18004;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /docs {
proxy_pass http://127.0.0.1:18004;
proxy_set_header Host $host;
}
location /openapi.json {
proxy_pass http://127.0.0.1:18004;
proxy_set_header Host $host;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# 从本机发布到 105 服务器 /root/wordloop
# 用法: ./deploy/publish.sh
# ./deploy/publish.sh root@120.26.184.105
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SSH_TARGET="${1:-${SSH_TARGET:-root@120.26.184.105}}"
# 可选:deploy/secrets.env 覆盖数据库等变量
if [ -f "$ROOT/deploy/secrets.env" ]; then
# shellcheck source=/dev/null
source "$ROOT/deploy/secrets.env"
fi
MYSQL_HOST="${MYSQL_HOST:-rm-uf6h1j53vtuxi78i90o.mysql.rds.aliyuncs.com}"
MYSQL_PORT="${MYSQL_PORT:-3306}"
MYSQL_USER="${MYSQL_USER:-boot}"
# %40 表示 @
MYSQL_PASSWORD="${MYSQL_PASSWORD:-@Abc888888}"
MYSQL_DATABASE="${MYSQL_DATABASE:-wordloop}"
echo ">>> 同步代码到 ${SSH_TARGET}:/root/wordloop/"
ssh -o ConnectTimeout=15 "$SSH_TARGET" "mkdir -p /root/wordloop"
rsync -avz --delete \
--exclude 'backend/venv' \
--exclude 'backend/__pycache__' \
--exclude 'backend/.env' \
--exclude 'backend/wordloop.db' \
--exclude 'backend/data/cache' \
--exclude 'frontend/node_modules' \
--exclude '.git' \
"$ROOT/" "${SSH_TARGET}:/root/wordloop/"
echo ">>> 远程安装与启动"
ssh "$SSH_TARGET" \
"MYSQL_HOST='${MYSQL_HOST}' \
MYSQL_PORT='${MYSQL_PORT}' \
MYSQL_USER='${MYSQL_USER}' \
MYSQL_PASSWORD='${MYSQL_PASSWORD}' \
MYSQL_DATABASE='${MYSQL_DATABASE}' \
bash /root/wordloop/deploy/install-production.sh"
echo ">>> 发布完成: http://120.26.184.105/ Cloudflare 请将 w 解析到该 IP"
+7
View File
@@ -0,0 +1,7 @@
# 复制为 secrets.env(已 gitignore),publish.sh 会自动加载
MYSQL_HOST=rm-uf6h1j53vtuxi78i90o.mysql.rds.aliyuncs.com
MYSQL_PORT=3306
MYSQL_USER=boot
MYSQL_PASSWORD=@Abc888888
MYSQL_DATABASE=wordloop
# SSH_TARGET=root@120.26.184.105
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# 交互式辅助:创建 Cloudflare Tunnel 并绑定 w.tkmind.cn
# 需已安装 cloudflared 且可访问 Cloudflare 账号
set -e
HOSTNAME="${WORDLOOP_HOSTNAME:-w.tkmind.cn}"
TUNNEL_NAME="${WORDLOOP_TUNNEL_NAME:-wordloop}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
CF_DIR="$ROOT/deploy/cloudflared"
if ! command -v cloudflared >/dev/null 2>&1; then
echo "请先安装 cloudflared: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/"
exit 1
fi
mkdir -p "$CF_DIR"
echo ">>> 1/4 登录 Cloudflare(将打开浏览器)..."
cloudflared tunnel login
echo ">>> 2/4 创建隧道: $TUNNEL_NAME"
cloudflared tunnel create "$TUNNEL_NAME" || true
CRED=$(ls -1 "$HOME/.cloudflared"/*.json 2>/dev/null | head -1)
if [ -z "$CRED" ]; then
echo "未找到 credentials JSON,请检查 ~/.cloudflared/"
exit 1
fi
CONFIG="$CF_DIR/config.yml"
if [ ! -f "$CONFIG" ]; then
sed -e "s|/path/to/.cloudflared/<TUNNEL-UUID>.json|$CRED|" \
-e "s|hostname: w.tkmind.cn|hostname: $HOSTNAME|" \
"$CF_DIR/config.yml.example" > "$CONFIG"
echo "已生成 $CONFIG"
else
echo "保留已有 $CONFIG"
fi
echo ">>> 3/4 DNS: $HOSTNAME -> 隧道 $TUNNEL_NAME"
cloudflared tunnel route dns "$TUNNEL_NAME" "$HOSTNAME"
echo ">>> 4/4 请先确保 Nginx 在 127.0.0.1:8080 提供 WordLoop,然后运行:"
echo " cloudflared tunnel --config $CONFIG run"
echo ""
echo "生产环境可复制 systemd 单元:deploy/systemd/cloudflared-wordloop.service"
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# 在 105 服务器上为 w.tkmind.cn 签发 Let's Encrypt 并 reload nginx
set -euo pipefail
DOMAIN=w.tkmind.cn
WEBROOT=/root/wordloop/frontend/dist
EMAIL="${CERTBOT_EMAIL:-admin@tkmind.cn}"
# 先确保 80 可访问(用于 ACME)
cp /root/wordloop/deploy/wordloop-locations.inc /etc/nginx/conf.d/wordloop-locations.inc
cp /root/wordloop/deploy/nginx-production.conf /etc/nginx/conf.d/wordloop.conf
# 若尚无证书,临时注释 443 块会导致语法错误 — 仅用 certbot 独立签发的 80 配置
if [ ! -f "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" ]; then
cat > /etc/nginx/conf.d/wordloop.conf <<'NGINX80'
server {
listen 80;
server_name w.tkmind.cn;
root /root/wordloop/frontend/dist;
location /.well-known/acme-challenge/ { root /root/wordloop/frontend/dist; }
location / { try_files $uri $uri/ /index.html; }
}
NGINX80
nginx -t && systemctl reload nginx
certbot certonly --webroot -w "$WEBROOT" -d "$DOMAIN" \
--non-interactive --agree-tos -m "$EMAIL" \
--preferred-challenges http
fi
cp /root/wordloop/deploy/wordloop-locations.inc /etc/nginx/conf.d/wordloop-locations.inc
cp /root/wordloop/deploy/nginx-production.conf /etc/nginx/conf.d/wordloop.conf
nginx -t
systemctl reload nginx
echo "SSL ready for https://${DOMAIN}/"
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# 本地 MySQL -> 生产 RDS(经 105 服务器导入)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SSH_TARGET="${SSH_TARGET:-root@120.26.184.105}"
REMOTE_SQL="/tmp/wordloop-sync.sql.gz"
LOCAL_HOST="${LOCAL_MYSQL_HOST:-localhost}"
LOCAL_PORT="${LOCAL_MYSQL_PORT:-3306}"
LOCAL_USER="${LOCAL_MYSQL_USER:-boot}"
LOCAL_PASS="${LOCAL_MYSQL_PASSWORD:-888888}"
LOCAL_DB="${LOCAL_MYSQL_DATABASE:-wordloop}"
RDS_HOST="${RDS_HOST:-rm-uf6h1j53vtuxi78i90o.mysql.rds.aliyuncs.com}"
RDS_PORT="${RDS_PORT:-3306}"
RDS_USER="${RDS_USER:-boot}"
RDS_PASS="${RDS_PASSWORD:-@Abc888888}"
RDS_DB="${RDS_DATABASE:-wordloop}"
TABLES="users user_settings words quiz_records dictionary_entries"
DUMP="$(mktemp /tmp/wordloop-sync.XXXXXX.sql.gz)"
cleanup() { rm -f "${DUMP%.gz}" "$DUMP" 2>/dev/null || true; }
trap cleanup EXIT
echo ">>> 1/3 导出本地库 (${LOCAL_DB})..."
mysqldump -h "$LOCAL_HOST" -P "$LOCAL_PORT" -u "$LOCAL_USER" -p"$LOCAL_PASS" \
--single-transaction --quick --extended-insert --add-drop-table \
--set-gtid-purged=OFF --no-tablespaces \
"$LOCAL_DB" $TABLES | gzip -c > "$DUMP"
ls -lh "$DUMP"
echo ">>> 2/3 上传到 ${SSH_TARGET}..."
scp "$DUMP" "${SSH_TARGET}:${REMOTE_SQL}"
echo ">>> 3/3 导入生产 RDS (${RDS_HOST})..."
ssh "$SSH_TARGET" "gunzip -c ${REMOTE_SQL} | mysql -h '${RDS_HOST}' -P '${RDS_PORT}' -u '${RDS_USER}' -p'${RDS_PASS}' '${RDS_DB}' && rm -f ${REMOTE_SQL}"
echo ">>> 校验生产库行数..."
ssh "$SSH_TARGET" "cd /root/wordloop/backend && source venv/bin/activate && python3 - <<'PY'
from sqlalchemy import text
from database import engine
with engine.connect() as c:
for t in ['users','words','dictionary_entries','user_settings','quiz_records']:
print(f\"{t}:\", c.execute(text(f'SELECT COUNT(*) FROM {t}')).scalar())
PY"
echo ">>> 同步完成"
@@ -0,0 +1,13 @@
[Unit]
Description=Cloudflare Tunnel for WordLoop (w.tkmind.cn)
After=network-online.target nginx.service
Wants=network-online.target
[Service]
Type=notify
ExecStart=/usr/local/bin/cloudflared tunnel --config /var/www/wordloop/deploy/cloudflared/config.yml run
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,15 @@
[Unit]
Description=WordLoop FastAPI backend
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/root/wordloop/backend
EnvironmentFile=/root/wordloop/backend/.env
ExecStart=/root/wordloop/backend/venv/bin/uvicorn main:app --host 127.0.0.1 --port 18004
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=WordLoop FastAPI backend
After=network.target mysql.service
[Service]
Type=simple
User=www-data
WorkingDirectory=/var/www/wordloop/backend
EnvironmentFile=/var/www/wordloop/backend/.env
ExecStart=/var/www/wordloop/backend/venv/bin/uvicorn main:app --host 127.0.0.1 --port 18004
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
+37
View File
@@ -0,0 +1,37 @@
# 由 install-production.sh 复制到 /etc/nginx/conf.d/wordloop-locations.inc
location /.well-known/acme-challenge/ {
try_files $uri =404;
}
location /api/ {
proxy_pass http://127.0.0.1:18004;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /docs {
proxy_pass http://127.0.0.1:18004;
proxy_set_header Host $host;
}
location /openapi.json {
proxy_pass http://127.0.0.1:18004;
proxy_set_header Host $host;
}
# 静态资源不存在时必须 404,不能回退 index.html(否则 JS 加载失败页面空白)
location ^~ /assets/ {
try_files $uri =404;
expires 7d;
add_header Cache-Control "public, immutable";
}
# Vue Router history/login 等路径回退 index.html
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>WordLoop 单词循环记忆</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1893
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "wordloop-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.9",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "~5.6.2",
"vite": "^6.0.3",
"vue-tsc": "^2.1.10"
}
}
+3
View File
@@ -0,0 +1,3 @@
<template>
<router-view />
</template>
+115
View File
@@ -0,0 +1,115 @@
import axios from 'axios'
const request = axios.create({
baseURL: '/api',
timeout: 15000,
})
request.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
request.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('token')
if (!window.location.pathname.includes('/login')) {
window.location.href = '/login'
}
}
return Promise.reject(err)
}
)
export default request
export interface Word {
id: number
source_text: string
target_text: string
source_lang: string
target_lang: string
phonetic?: string
example_en?: string
example_cn?: string
status: string
correct_count: number
wrong_count: number
consecutive_correct_count: number
mastery_score: number
review_due_date?: string
last_reviewed_at?: string
created_at: string
}
export interface TranslateResult {
source_text: string
target_text: string
source_lang: string
target_lang: string
phonetic?: string
example_en?: string
example_cn?: string
}
export interface QuizOption {
label: string
text: string
}
export interface QuizQuestion {
word_id: number
question_type: string
prompt: string
options: QuizOption[]
correct_answer: string
}
export interface QuizStats {
total_words: number
new_count: number
learning_count: number
mastered_count: number
weak_count: number
today_quiz_count: number
today_correct_count: number
today_accuracy: number
daily_target: number
today_completed: number
streak_days: number
}
export interface Settings {
daily_target: number
master_required_count: number
weak_wrong_threshold: number
}
export const api = {
register: (username: string, password: string) =>
request.post('/auth/register', { username, password }),
login: (username: string, password: string) =>
request.post<{ access_token: string }>('/auth/login', { username, password }),
me: () => request.get('/auth/me'),
translate: (text: string) => request.post<TranslateResult>('/translate', { text }),
createWord: (data: Partial<Word>) => request.post<Word>('/words', data),
listWords: (status?: string) =>
request.get<Word[]>('/words', { params: status ? { status } : {} }),
deleteWord: (id: number) => request.delete(`/words/${id}`),
dailyQuiz: () =>
request.get<{ questions: QuizQuestion[]; total: number }>('/quiz/daily'),
submitAnswer: (data: {
word_id: number
question_type: string
user_answer: string
correct_answer: string
}) => request.post('/quiz/answer', data),
quizStats: () => request.get<QuizStats>('/quiz/stats'),
getSettings: () => request.get<Settings>('/settings'),
updateSettings: (data: Partial<Settings>) => request.patch<Settings>('/settings', data),
}
+107
View File
@@ -0,0 +1,107 @@
<script setup lang="ts">
import type { QuizQuestion } from '../api/request'
defineProps<{
question: QuizQuestion
index: number
total: number
selected?: string
showResult?: boolean
isCorrect?: boolean
}>()
defineEmits<{
select: [answer: string]
}>()
const typeLabel: Record<string, string> = {
en_to_zh: '看英文选中文',
zh_to_en: '看中文选英文',
}
</script>
<template>
<div class="card quiz-card">
<div class="quiz-progress">{{ index + 1 }} / {{ total }}</div>
<div class="quiz-type">{{ typeLabel[question.question_type] || question.question_type }}</div>
<div class="quiz-prompt">{{ question.prompt }}</div>
<div class="options">
<button
v-for="opt in question.options"
:key="opt.label"
class="option-btn"
:class="{
selected: selected === opt.text,
correct: showResult && opt.text === question.correct_answer,
wrong: showResult && selected === opt.text && opt.text !== question.correct_answer,
}"
:disabled="showResult"
@click="$emit('select', opt.text)"
>
<span class="opt-label">{{ opt.label }}.</span> {{ opt.text }}
</button>
</div>
<div v-if="showResult" class="result" :class="isCorrect ? 'ok' : 'fail'">
{{ isCorrect ? '回答正确 ' : '回答错误 ' }}
<template v-if="!isCorrect"> 正确答案{{ question.correct_answer }}</template>
</div>
</div>
</template>
<style scoped>
.quiz-progress {
font-size: 13px;
color: var(--muted);
margin-bottom: 8px;
}
.quiz-type {
font-size: 12px;
color: var(--primary);
margin-bottom: 8px;
}
.quiz-prompt {
font-size: 28px;
font-weight: 700;
text-align: center;
margin: 20px 0;
}
.options {
display: flex;
flex-direction: column;
gap: 10px;
}
.option-btn {
padding: 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
font-size: 15px;
text-align: left;
cursor: pointer;
}
.option-btn.selected {
border-color: var(--primary);
background: rgba(79, 110, 247, 0.08);
}
.option-btn.correct {
border-color: var(--success);
background: #d1fae5;
}
.option-btn.wrong {
border-color: var(--danger);
background: #fee2e2;
}
.opt-label {
font-weight: 700;
margin-right: 6px;
}
.result {
margin-top: 16px;
padding: 12px;
border-radius: var(--radius);
font-size: 14px;
text-align: center;
}
.result.ok { background: #d1fae5; color: #047857; }
.result.fail { background: #fee2e2; color: #b91c1c; }
</style>
+72
View File
@@ -0,0 +1,72 @@
<script setup lang="ts">
import type { Word } from '../api/request'
defineProps<{
word: Word
}>()
defineEmits<{
delete: [id: number]
}>()
const statusMap: Record<string, string> = {
new: '新词',
learning: '学习中',
mastered: '已掌握',
weak: '易错词',
}
function enText(word: Word) {
return word.source_lang === 'en' ? word.source_text : word.target_text
}
function zhText(word: Word) {
return word.source_lang === 'zh' ? word.source_text : word.target_text
}
</script>
<template>
<div class="card word-card">
<div class="word-header">
<div>
<div class="word-pair">{{ enText(word) }} {{ zhText(word) }}</div>
<span :class="['badge', `badge-${word.status}`]">{{ statusMap[word.status] || word.status }}</span>
</div>
<button class="btn btn-danger" @click="$emit('delete', word.id)">删除</button>
</div>
<div class="word-meta">
<span>答对 {{ word.correct_count }}</span>
<span>答错 {{ word.wrong_count }}</span>
<span>连续 {{ word.consecutive_correct_count }}</span>
<span>掌握率 {{ word.mastery_score }}%</span>
</div>
<div v-if="word.review_due_date" class="word-due">下次复习{{ word.review_due_date }}</div>
</div>
</template>
<style scoped>
.word-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
}
.word-pair {
font-size: 17px;
font-weight: 600;
margin-bottom: 6px;
}
.word-meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
font-size: 12px;
color: var(--muted);
margin-top: 10px;
}
.word-due {
font-size: 12px;
color: var(--primary);
margin-top: 6px;
}
</style>
+27
View File
@@ -0,0 +1,27 @@
<template>
<div class="main-layout">
<router-view />
<nav class="nav-bottom">
<router-link to="/" class="nav-item">
<span class="nav-icon">📊</span>
首页
</router-link>
<router-link to="/translate" class="nav-item">
<span class="nav-icon">🔤</span>
翻译
</router-link>
<router-link to="/words" class="nav-item">
<span class="nav-icon">📚</span>
词库
</router-link>
<router-link to="/quiz" class="nav-item">
<span class="nav-icon"></span>
训练
</router-link>
<router-link to="/settings" class="nav-item">
<span class="nav-icon"></span>
设置
</router-link>
</nav>
</div>
</template>
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './styles.css'
createApp(App).use(router).mount('#app')
+125
View File
@@ -0,0 +1,125 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import QuizCard from '../components/QuizCard.vue'
import { api, type QuizQuestion } from '../api/request'
const questions = ref<QuizQuestion[]>([])
const currentIndex = ref(0)
const selected = ref('')
const showResult = ref(false)
const isCorrect = ref(false)
const loading = ref(true)
const finished = ref(false)
const sessionCorrect = ref(0)
const sessionWrong = ref(0)
const empty = ref(false)
const current = () => questions.value[currentIndex.value]
onMounted(async () => {
try {
const { data } = await api.dailyQuiz()
questions.value = data.questions
if (data.questions.length === 0) {
empty.value = true
}
} finally {
loading.value = false
}
})
async function onSelect(answer: string) {
if (showResult.value) return
selected.value = answer
const q = current()
if (!q) return
try {
const { data } = await api.submitAnswer({
word_id: q.word_id,
question_type: q.question_type,
user_answer: answer,
correct_answer: q.correct_answer,
})
isCorrect.value = data.is_correct
if (data.is_correct) sessionCorrect.value++
else sessionWrong.value++
} catch {
isCorrect.value = answer === q.correct_answer
if (isCorrect.value) sessionCorrect.value++
else sessionWrong.value++
}
showResult.value = true
}
function nextQuestion() {
if (currentIndex.value >= questions.value.length - 1) {
finished.value = true
return
}
currentIndex.value++
selected.value = ''
showResult.value = false
isCorrect.value = false
}
const accuracy = () => {
const total = sessionCorrect.value + sessionWrong.value
return total ? Math.round((sessionCorrect.value / total) * 100) : 0
}
</script>
<template>
<div class="page">
<h1 class="page-title">每日训练</h1>
<p v-if="loading" style="color: var(--muted)">加载题目...</p>
<div v-else-if="empty" class="card" style="text-align: center">
<p>词库单词不足请先通过翻译添加至少 4 个单词</p>
<router-link to="/translate" class="btn btn-primary" style="margin-top: 12px; display: inline-block">
去翻译
</router-link>
</div>
<div v-else-if="finished" class="card summary">
<h2>本次训练完成 🎉</h2>
<p>总题数{{ sessionCorrect + sessionWrong }}</p>
<p>答对{{ sessionCorrect }}</p>
<p>答错{{ sessionWrong }}</p>
<p>正确率{{ accuracy() }}%</p>
<router-link to="/" class="btn btn-primary" style="margin-top: 16px">返回首页</router-link>
</div>
<template v-else-if="current()">
<QuizCard
:question="current()!"
:index="currentIndex"
:total="questions.length"
:selected="selected"
:show-result="showResult"
:is-correct="isCorrect"
@select="onSelect"
/>
<button
v-if="showResult"
class="btn btn-primary"
style="margin-top: 12px"
@click="nextQuestion"
>
{{ currentIndex >= questions.length - 1 ? '查看结果' : '下一题' }}
</button>
</template>
</div>
</template>
<style scoped>
.summary h2 {
margin-bottom: 12px;
font-size: 20px;
}
.summary p {
margin: 6px 0;
font-size: 15px;
}
</style>
+85
View File
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api, type QuizStats } from '../api/request'
const stats = ref<QuizStats | null>(null)
const loading = ref(true)
onMounted(async () => {
try {
const { data } = await api.quizStats()
stats.value = data
} finally {
loading.value = false
}
})
</script>
<template>
<div class="page">
<h1 class="page-title">WordLoop</h1>
<p v-if="loading" style="color: var(--muted)">加载中...</p>
<template v-else-if="stats">
<div class="card highlight-card">
<div class="highlight-title">今日复习</div>
<div class="highlight-value">
{{ stats.today_completed }} / {{ stats.daily_target }}
</div>
<div class="highlight-sub">
正确率 {{ stats.today_accuracy }}% · 连续学习 {{ stats.streak_days }}
</div>
</div>
<div class="stat-grid">
<div class="stat-card">
<div class="stat-value">{{ stats.total_words }}</div>
<div class="stat-label">总单词数</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.mastered_count }}</div>
<div class="stat-label">已掌握</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.learning_count }}</div>
<div class="stat-label">学习中</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.weak_count }}</div>
<div class="stat-label">易错词</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.new_count }}</div>
<div class="stat-label">新词</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.today_accuracy }}%</div>
<div class="stat-label">今日正确率</div>
</div>
</div>
<div class="quick-actions">
<router-link to="/translate" class="btn btn-outline">去翻译</router-link>
<router-link to="/words" class="btn btn-outline">单词库</router-link>
<router-link to="/quiz" class="btn btn-primary">开始每日训练</router-link>
</div>
</template>
</div>
</template>
<style scoped>
.highlight-card {
background: linear-gradient(135deg, #4f6ef7, #6b8cff);
color: #fff;
margin-bottom: 16px;
}
.highlight-title { font-size: 14px; opacity: 0.9; }
.highlight-value { font-size: 36px; font-weight: 800; margin: 8px 0; }
.highlight-sub { font-size: 13px; opacity: 0.85; }
.quick-actions {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 20px;
}
.quick-actions .btn { text-decoration: none; }
</style>
+76
View File
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../api/request'
const router = useRouter()
const username = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
async function handleLogin() {
error.value = ''
if (!username.value || !password.value) {
error.value = '请填写用户名和密码'
return
}
loading.value = true
try {
const { data } = await api.login(username.value, password.value)
localStorage.setItem('token', data.access_token)
router.push('/')
} catch (e: unknown) {
const err = e as { response?: { data?: { detail?: string } } }
error.value = err.response?.data?.detail || '登录失败'
} finally {
loading.value = false
}
}
</script>
<template>
<div class="page auth-page">
<div class="auth-logo">WordLoop</div>
<p class="auth-sub">单词循环记忆系统</p>
<div v-if="error" class="message error">{{ error }}</div>
<div class="card">
<label class="label">用户名</label>
<input v-model="username" class="input" placeholder="请输入用户名" />
<label class="label">密码</label>
<input v-model="password" type="password" class="input" placeholder="请输入密码" />
<button class="btn btn-primary" style="margin-top: 16px" :disabled="loading" @click="handleLogin">
{{ loading ? '登录中...' : '登录' }}
</button>
</div>
<p class="auth-link">还没有账号<router-link to="/register">立即注册</router-link></p>
</div>
</template>
<style scoped>
.auth-page {
padding-top: 60px;
}
.auth-logo {
font-size: 32px;
font-weight: 800;
color: var(--primary);
text-align: center;
}
.auth-sub {
text-align: center;
color: var(--muted);
margin: 8px 0 24px;
}
.label {
display: block;
font-size: 13px;
color: var(--muted);
margin: 12px 0 6px;
}
.auth-link {
text-align: center;
margin-top: 20px;
font-size: 14px;
}
</style>
+80
View File
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '../api/request'
const router = useRouter()
const username = ref('')
const password = ref('')
const confirm = ref('')
const error = ref('')
const loading = ref(false)
async function handleRegister() {
error.value = ''
if (!username.value || !password.value) {
error.value = '请填写完整信息'
return
}
if (password.value !== confirm.value) {
error.value = '两次密码不一致'
return
}
if (password.value.length < 6) {
error.value = '密码至少 6 位'
return
}
loading.value = true
try {
await api.register(username.value, password.value)
const { data } = await api.login(username.value, password.value)
localStorage.setItem('token', data.access_token)
router.push('/')
} catch (e: unknown) {
const err = e as { response?: { data?: { detail?: string } } }
error.value = err.response?.data?.detail || '注册失败'
} finally {
loading.value = false
}
}
</script>
<template>
<div class="page auth-page">
<div class="auth-logo">注册账号</div>
<div v-if="error" class="message error">{{ error }}</div>
<div class="card">
<label class="label">用户名</label>
<input v-model="username" class="input" placeholder="2-50 个字符" />
<label class="label">密码</label>
<input v-model="password" type="password" class="input" placeholder="至少 6 位" />
<label class="label">确认密码</label>
<input v-model="confirm" type="password" class="input" placeholder="再次输入密码" />
<button class="btn btn-primary" style="margin-top: 16px" :disabled="loading" @click="handleRegister">
{{ loading ? '注册中...' : '注册' }}
</button>
</div>
<p class="auth-link">已有账号<router-link to="/login">去登录</router-link></p>
</div>
</template>
<style scoped>
.auth-page { padding-top: 40px; }
.auth-logo {
font-size: 26px;
font-weight: 700;
text-align: center;
margin-bottom: 20px;
}
.label {
display: block;
font-size: 13px;
color: var(--muted);
margin: 12px 0 6px;
}
.auth-link {
text-align: center;
margin-top: 20px;
font-size: 14px;
}
</style>
+70
View File
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { api, type Settings } from '../api/request'
const settings = ref<Settings>({
daily_target: 20,
master_required_count: 3,
weak_wrong_threshold: 3,
})
const message = ref('')
const loading = ref(false)
onMounted(async () => {
const { data } = await api.getSettings()
settings.value = data
})
async function save() {
loading.value = true
message.value = ''
try {
const { data } = await api.updateSettings(settings.value)
settings.value = data
message.value = '设置已保存 ✅'
} catch {
message.value = '保存失败'
} finally {
loading.value = false
}
}
function logout() {
localStorage.removeItem('token')
window.location.href = '/login'
}
</script>
<template>
<div class="page">
<h1 class="page-title">学习设置</h1>
<div v-if="message" class="message success">{{ message }}</div>
<div class="card">
<label class="label">每日训练数量</label>
<input v-model.number="settings.daily_target" type="number" min="1" max="100" class="input" />
<label class="label">连续答对几次算掌握</label>
<input v-model.number="settings.master_required_count" type="number" min="1" max="20" class="input" />
<label class="label">累计错几次进入易错词</label>
<input v-model.number="settings.weak_wrong_threshold" type="number" min="1" max="20" class="input" />
<button class="btn btn-primary" style="margin-top: 16px" :disabled="loading" @click="save">
{{ loading ? '保存中...' : '保存设置' }}
</button>
</div>
<button class="btn btn-outline" style="margin-top: 20px; width: 100%" @click="logout">
退出登录
</button>
</div>
</template>
<style scoped>
.label {
display: block;
font-size: 13px;
color: var(--muted);
margin: 14px 0 6px;
}
.label:first-child { margin-top: 0; }
</style>
+124
View File
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { ref } from 'vue'
import { api, type TranslateResult } from '../api/request'
const input = ref('')
const result = ref<TranslateResult | null>(null)
const message = ref('')
const messageType = ref<'success' | 'error'>('success')
const loading = ref(false)
async function handleTranslate() {
if (!input.value.trim()) return
loading.value = true
message.value = ''
result.value = null
try {
const { data } = await api.translate(input.value.trim())
result.value = data
} catch (e: unknown) {
const err = e as { response?: { data?: { detail?: string } } }
message.value = err.response?.data?.detail || '翻译失败'
messageType.value = 'error'
} finally {
loading.value = false
}
}
async function addToLibrary() {
if (!result.value) return
message.value = ''
try {
await api.createWord({
source_text: result.value.source_text,
target_text: result.value.target_text,
source_lang: result.value.source_lang,
target_lang: result.value.target_lang,
phonetic: result.value.phonetic,
example_en: result.value.example_en,
example_cn: result.value.example_cn,
})
message.value = '已加入单词库 ✅'
messageType.value = 'success'
} catch (e: unknown) {
const err = e as { response?: { data?: { detail?: string } } }
message.value = err.response?.data?.detail || '添加失败'
messageType.value = 'error'
}
}
const langLabel = (s: string, t: string) =>
s === 'zh' ? '中文 → 英文' : '英文 → 中文'
</script>
<template>
<div class="page">
<h1 class="page-title">翻译</h1>
<textarea
v-model="input"
class="input translate-input"
rows="3"
placeholder="输入中文或英文,例如:苹果 或 apple"
/>
<button class="btn btn-primary" style="margin-top: 12px" :disabled="loading" @click="handleTranslate">
{{ loading ? '翻译中...' : '翻译' }}
</button>
<div v-if="message" :class="['message', messageType]">{{ message }}</div>
<div v-if="result" class="card result-card">
<div class="lang-dir">{{ langLabel(result.source_lang, result.target_lang) }}</div>
<div class="result-row">
<span class="label-sm">原文</span>
<span>{{ result.source_text }}</span>
</div>
<div class="result-row main">
<span class="label-sm">译文</span>
<span>{{ result.target_text }}</span>
</div>
<div v-if="result.phonetic" class="result-row">
<span class="label-sm">音标</span>
<span>{{ result.phonetic }}</span>
</div>
<div v-if="result.example_en" class="result-row">
<span class="label-sm">英文例句</span>
<span>{{ result.example_en }}</span>
</div>
<div v-if="result.example_cn" class="result-row">
<span class="label-sm">中文例句</span>
<span>{{ result.example_cn }}</span>
</div>
<button class="btn btn-primary" style="margin-top: 14px" @click="addToLibrary">
加入单词库
</button>
</div>
</div>
</template>
<style scoped>
.translate-input {
resize: vertical;
min-height: 80px;
font-family: inherit;
}
.result-card { margin-top: 16px; }
.lang-dir {
font-size: 12px;
color: var(--primary);
margin-bottom: 12px;
}
.result-row {
margin-bottom: 10px;
font-size: 15px;
}
.result-row.main {
font-size: 22px;
font-weight: 700;
}
.label-sm {
display: block;
font-size: 12px;
color: var(--muted);
margin-bottom: 2px;
}
</style>
+62
View File
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import WordCard from '../components/WordCard.vue'
import { api, type Word } from '../api/request'
const tabs = [
{ key: '', label: '全部' },
{ key: 'new', label: '新词' },
{ key: 'learning', label: '学习中' },
{ key: 'mastered', label: '已掌握' },
{ key: 'weak', label: '易错词' },
]
const activeTab = ref('')
const words = ref<Word[]>([])
const loading = ref(true)
async function loadWords() {
loading.value = true
try {
const { data } = await api.listWords(activeTab.value || undefined)
words.value = data
} finally {
loading.value = false
}
}
async function handleDelete(id: number) {
if (!confirm('确定删除这个单词?')) return
await api.deleteWord(id)
await loadWords()
}
watch(activeTab, loadWords)
onMounted(loadWords)
</script>
<template>
<div class="page">
<h1 class="page-title">单词库</h1>
<div class="tabs">
<button
v-for="t in tabs"
:key="t.key"
:class="['tab', { active: activeTab === t.key }]"
@click="activeTab = t.key"
>
{{ t.label }}
</button>
</div>
<p v-if="loading" style="color: var(--muted)">加载中...</p>
<p v-else-if="words.length === 0" style="color: var(--muted); text-align: center">
暂无单词去翻译页添加吧
</p>
<WordCard
v-for="w in words"
:key="w.id"
:word="w"
@delete="handleDelete"
/>
</div>
</template>
+34
View File
@@ -0,0 +1,34 @@
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', name: 'Login', component: () => import('../pages/Login.vue') },
{ path: '/register', name: 'Register', component: () => import('../pages/Register.vue') },
{
path: '/',
component: () => import('../layouts/MainLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Dashboard', component: () => import('../pages/Dashboard.vue') },
{ path: 'translate', name: 'Translate', component: () => import('../pages/Translate.vue') },
{ path: 'words', name: 'WordLibrary', component: () => import('../pages/WordLibrary.vue') },
{ path: 'quiz', name: 'DailyQuiz', component: () => import('../pages/DailyQuiz.vue') },
{ path: 'settings', name: 'Settings', component: () => import('../pages/Settings.vue') },
],
},
],
})
router.beforeEach((to, _from, next) => {
const token = localStorage.getItem('token')
if (to.meta.requiresAuth && !token) {
next('/login')
} else if ((to.path === '/login' || to.path === '/register') && token) {
next('/')
} else {
next()
}
})
export default router
+227
View File
@@ -0,0 +1,227 @@
:root {
--primary: #4f6ef7;
--primary-dark: #3d57d4;
--bg: #f4f6fb;
--card: #ffffff;
--text: #1a1d26;
--muted: #6b7280;
--success: #10b981;
--danger: #ef4444;
--border: #e5e7eb;
--radius: 12px;
--shadow: 0 2px 12px rgba(79, 110, 247, 0.08);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC',
'Microsoft YaHei', sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
#app {
min-height: 100vh;
}
a {
color: var(--primary);
text-decoration: none;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 12px 20px;
border: none;
border-radius: var(--radius);
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s, transform 0.1s;
}
.btn:active {
transform: scale(0.98);
}
.btn-primary {
background: var(--primary);
color: #fff;
width: 100%;
}
.btn-primary:hover {
background: var(--primary-dark);
}
.btn-outline {
background: transparent;
border: 1px solid var(--primary);
color: var(--primary);
}
.btn-danger {
background: #fee2e2;
color: var(--danger);
padding: 8px 12px;
font-size: 13px;
}
.input {
width: 100%;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 16px;
outline: none;
background: #fff;
}
.input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(79, 110, 247, 0.15);
}
.card {
background: var(--card);
border-radius: var(--radius);
padding: 16px;
box-shadow: var(--shadow);
margin-bottom: 12px;
}
.page {
max-width: 480px;
margin: 0 auto;
padding: 16px;
padding-bottom: 80px;
}
.page-title {
font-size: 22px;
font-weight: 700;
margin-bottom: 16px;
}
.tabs {
display: flex;
gap: 8px;
overflow-x: auto;
margin-bottom: 16px;
padding-bottom: 4px;
}
.tab {
flex-shrink: 0;
padding: 8px 14px;
border-radius: 20px;
font-size: 13px;
background: #fff;
border: 1px solid var(--border);
cursor: pointer;
color: var(--muted);
}
.tab.active {
background: var(--primary);
color: #fff;
border-color: var(--primary);
}
.nav-bottom {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #fff;
border-top: 1px solid var(--border);
display: flex;
justify-content: space-around;
padding: 8px 0 calc(8px + env(safe-area-inset-bottom));
z-index: 100;
}
.nav-item {
display: flex;
flex-direction: column;
align-items: center;
font-size: 11px;
color: var(--muted);
padding: 4px 12px;
text-decoration: none;
}
.nav-item.router-link-active {
color: var(--primary);
font-weight: 600;
}
.nav-icon {
font-size: 20px;
margin-bottom: 2px;
}
.stat-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.stat-card {
background: var(--card);
border-radius: var(--radius);
padding: 14px;
text-align: center;
box-shadow: var(--shadow);
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: var(--primary);
}
.stat-label {
font-size: 12px;
color: var(--muted);
margin-top: 4px;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
}
.badge-new { background: #dbeafe; color: #1d4ed8; }
.badge-learning { background: #fef3c7; color: #b45309; }
.badge-mastered { background: #d1fae5; color: #047857; }
.badge-weak { background: #fee2e2; color: #b91c1c; }
.message {
padding: 10px 14px;
border-radius: var(--radius);
margin-bottom: 12px;
font-size: 14px;
}
.message.error {
background: #fee2e2;
color: var(--danger);
}
.message.success {
background: #d1fae5;
color: #047857;
}
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, unknown>
export default component
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"jsx": "preserve",
"paths": {
"@/*": ["./src/*"]
},
"baseUrl": "."
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler"
},
"include": ["vite.config.ts"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/api/request.ts","./src/router/index.ts","./src/app.vue","./src/components/quizcard.vue","./src/components/wordcard.vue","./src/layouts/mainlayout.vue","./src/pages/dailyquiz.vue","./src/pages/dashboard.vue","./src/pages/login.vue","./src/pages/register.vue","./src/pages/settings.vue","./src/pages/translate.vue","./src/pages/wordlibrary.vue"],"version":"5.6.3"}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 18003,
proxy: {
'/api': {
target: 'http://localhost:18004',
changeOrigin: true,
},
},
},
})
Executable
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
set -e
ROOT="$(cd "$(dirname "$0")" && pwd)"
ensure_backend_venv() {
cd "$ROOT/backend"
if [ ! -d venv ]; then
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt -q
else
source venv/bin/activate
fi
}
run_backend() {
ensure_backend_venv
exec uvicorn main:app --reload --host 0.0.0.0 --port 18004
}
run_frontend() {
cd "$ROOT/frontend"
[ -d node_modules ] || npm install
exec npm run dev -- --host 0.0.0.0 --port 18003
}
run_all() {
ensure_backend_venv
cd "$ROOT/frontend"
[ -d node_modules ] || npm install
trap 'kill $(jobs -p) 2>/dev/null' EXIT INT TERM
echo "启动后端 http://localhost:18004 (API 文档 /docs)"
(cd "$ROOT/backend" && source venv/bin/activate && uvicorn main:app --reload --host 0.0.0.0 --port 18004) &
echo "启动前端 http://localhost:18003"
(cd "$ROOT/frontend" && npm run dev -- --host 0.0.0.0 --port 18003) &
echo "按 Ctrl+C 停止前后端"
wait
}
case "${1:-all}" in
backend) run_backend ;;
frontend) run_frontend ;;
all) run_all ;;
*)
echo "用法: ./start.sh [all|backend|frontend]"
echo ""
echo " ./start.sh # 同一终端同时启动前后端"
echo " ./start.sh all # 同上"
echo " ./start.sh backend # 仅后端 (需另开终端跑 frontend)"
echo " ./start.sh frontend # 仅前端"
echo ""
echo "或按 README 在两个终端分别执行 backend / frontend 下的命令。"
exit 1
;;
esac