Initial commit: WordLoop 单词学习应用
Vue 前端 + FastAPI 后端,含部署脚本与词典数据。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+24
@@ -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
|
||||||
@@ -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)
|
||||||
|
- 个人单词库(新词 / 学习中 / 已掌握 / 易错词)
|
||||||
|
- 每日选择题训练
|
||||||
|
- 掌握规则与复习间隔
|
||||||
|
- 学习统计与设置
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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": "我爱我的家人。"}
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
@@ -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"}
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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))
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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;
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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,直接进入第二节。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、两种接入方式(二选一)
|
||||||
|
|
||||||
|
### 方式 A:Cloudflare 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` — 前端生产构建
|
||||||
Executable
+13
@@ -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/"
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Cloudflare Tunnel 示例配置
|
||||||
|
# 1. 复制为 config.yml:cp 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
|
||||||
Executable
+98
@@ -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"
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# WordLoop 生产 — w.tkmind.cn(80 + 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+45
@@ -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)"
|
||||||
@@ -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
|
||||||
Executable
+46
@@ -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"
|
||||||
@@ -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}/"
|
||||||
Executable
+49
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Generated
+1893
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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')
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, unknown>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -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"}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user