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