From bd7635986a289a9d92aa20bb1246cb94ecf0aea4 Mon Sep 17 00:00:00 2001 From: John Date: Thu, 4 Jun 2026 14:30:53 -0700 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20WordLoop=20=E5=8D=95?= =?UTF-8?q?=E8=AF=8D=E5=AD=A6=E4=B9=A0=E5=BA=94=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vue 前端 + FastAPI 后端,含部署脚本与词典数据。 Co-authored-by: Cursor --- .gitignore | 24 + README.md | 162 ++ backend/.env.example | 8 + backend/auth.py | 56 + backend/config.py | 33 + backend/data/dictionary.json | 38 + backend/database.py | 23 + backend/main.py | 28 + backend/models.py | 71 + backend/requirements.txt | 9 + backend/routers/__init__.py | 0 backend/routers/auth_router.py | 55 + backend/routers/quiz_router.py | 49 + backend/routers/settings_router.py | 45 + backend/routers/translate_router.py | 20 + backend/routers/word_router.py | 60 + backend/schemas.py | 142 ++ backend/scripts/__init__.py | 0 backend/scripts/dictionary_importer.py | 83 + backend/scripts/import_dictionary.py | 83 + backend/scripts/import_dictionary_online.py | 179 ++ backend/scripts/init_mysql.sql | 4 + backend/services/__init__.py | 0 backend/services/dictionary_service.py | 55 + backend/services/quiz_service.py | 269 +++ backend/services/translation_service.py | 85 + backend/services/word_service.py | 83 + deploy/DEPLOY.md | 124 ++ deploy/build.sh | 13 + deploy/cloudflared/config.yml.example | 15 + deploy/install-production.sh | 98 + deploy/nginx-production.conf | 28 + deploy/nginx.conf | 37 + deploy/publish.sh | 45 + deploy/secrets.env.example | 7 + deploy/setup-tunnel.sh | 46 + deploy/ssl-w.tkmind.cn.sh | 33 + deploy/sync-db-to-production.sh | 49 + deploy/systemd/cloudflared-wordloop.service | 13 + deploy/systemd/wordloop-backend-root.service | 15 + deploy/systemd/wordloop-backend.service | 15 + deploy/wordloop-locations.inc | 37 + frontend/index.html | 12 + frontend/package-lock.json | 1893 ++++++++++++++++++ frontend/package.json | 22 + frontend/src/App.vue | 3 + frontend/src/api/request.ts | 115 ++ frontend/src/components/QuizCard.vue | 107 + frontend/src/components/WordCard.vue | 72 + frontend/src/layouts/MainLayout.vue | 27 + frontend/src/main.ts | 6 + frontend/src/pages/DailyQuiz.vue | 125 ++ frontend/src/pages/Dashboard.vue | 85 + frontend/src/pages/Login.vue | 76 + frontend/src/pages/Register.vue | 80 + frontend/src/pages/Settings.vue | 70 + frontend/src/pages/Translate.vue | 124 ++ frontend/src/pages/WordLibrary.vue | 62 + frontend/src/router/index.ts | 34 + frontend/src/styles.css | 227 +++ frontend/src/vite-env.d.ts | 7 + frontend/tsconfig.json | 24 + frontend/tsconfig.node.json | 10 + frontend/tsconfig.tsbuildinfo | 1 + frontend/vite.config.ts | 15 + start.sh | 59 + 66 files changed, 5495 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/.env.example create mode 100644 backend/auth.py create mode 100644 backend/config.py create mode 100644 backend/data/dictionary.json create mode 100644 backend/database.py create mode 100644 backend/main.py create mode 100644 backend/models.py create mode 100644 backend/requirements.txt create mode 100644 backend/routers/__init__.py create mode 100644 backend/routers/auth_router.py create mode 100644 backend/routers/quiz_router.py create mode 100644 backend/routers/settings_router.py create mode 100644 backend/routers/translate_router.py create mode 100644 backend/routers/word_router.py create mode 100644 backend/schemas.py create mode 100644 backend/scripts/__init__.py create mode 100644 backend/scripts/dictionary_importer.py create mode 100644 backend/scripts/import_dictionary.py create mode 100644 backend/scripts/import_dictionary_online.py create mode 100644 backend/scripts/init_mysql.sql create mode 100644 backend/services/__init__.py create mode 100644 backend/services/dictionary_service.py create mode 100644 backend/services/quiz_service.py create mode 100644 backend/services/translation_service.py create mode 100644 backend/services/word_service.py create mode 100644 deploy/DEPLOY.md create mode 100755 deploy/build.sh create mode 100644 deploy/cloudflared/config.yml.example create mode 100755 deploy/install-production.sh create mode 100644 deploy/nginx-production.conf create mode 100644 deploy/nginx.conf create mode 100755 deploy/publish.sh create mode 100644 deploy/secrets.env.example create mode 100755 deploy/setup-tunnel.sh create mode 100644 deploy/ssl-w.tkmind.cn.sh create mode 100755 deploy/sync-db-to-production.sh create mode 100644 deploy/systemd/cloudflared-wordloop.service create mode 100644 deploy/systemd/wordloop-backend-root.service create mode 100644 deploy/systemd/wordloop-backend.service create mode 100644 deploy/wordloop-locations.inc create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/request.ts create mode 100644 frontend/src/components/QuizCard.vue create mode 100644 frontend/src/components/WordCard.vue create mode 100644 frontend/src/layouts/MainLayout.vue create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/pages/DailyQuiz.vue create mode 100644 frontend/src/pages/Dashboard.vue create mode 100644 frontend/src/pages/Login.vue create mode 100644 frontend/src/pages/Register.vue create mode 100644 frontend/src/pages/Settings.vue create mode 100644 frontend/src/pages/Translate.vue create mode 100644 frontend/src/pages/WordLibrary.vue create mode 100644 frontend/src/router/index.ts create mode 100644 frontend/src/styles.css create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/tsconfig.tsbuildinfo create mode 100644 frontend/vite.config.ts create mode 100755 start.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f2811f8 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..a208d3e --- /dev/null +++ b/README.md @@ -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) +- 个人单词库(新词 / 学习中 / 已掌握 / 易错词) +- 每日选择题训练 +- 掌握规则与复习间隔 +- 学习统计与设置 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..03c52af --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..0aa67b5 --- /dev/null +++ b/backend/auth.py @@ -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 diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..82644d5 --- /dev/null +++ b/backend/config.py @@ -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() diff --git a/backend/data/dictionary.json b/backend/data/dictionary.json new file mode 100644 index 0000000..54672e0 --- /dev/null +++ b/backend/data/dictionary.json @@ -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": "我爱我的家人。"} +] diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..3be15d7 --- /dev/null +++ b/backend/database.py @@ -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() diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..e49328e --- /dev/null +++ b/backend/main.py @@ -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"} diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..cd4e04d --- /dev/null +++ b/backend/models.py @@ -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) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..0d19bc3 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/routers/__init__.py b/backend/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/routers/auth_router.py b/backend/routers/auth_router.py new file mode 100644 index 0000000..30eb10e --- /dev/null +++ b/backend/routers/auth_router.py @@ -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 diff --git a/backend/routers/quiz_router.py b/backend/routers/quiz_router.py new file mode 100644 index 0000000..bc4d09b --- /dev/null +++ b/backend/routers/quiz_router.py @@ -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) diff --git a/backend/routers/settings_router.py b/backend/routers/settings_router.py new file mode 100644 index 0000000..33a8d86 --- /dev/null +++ b/backend/routers/settings_router.py @@ -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, + ) diff --git a/backend/routers/translate_router.py b/backend/routers/translate_router.py new file mode 100644 index 0000000..db43174 --- /dev/null +++ b/backend/routers/translate_router.py @@ -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) diff --git a/backend/routers/word_router.py b/backend/routers/word_router.py new file mode 100644 index 0000000..e92e24b --- /dev/null +++ b/backend/routers/word_router.py @@ -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)) diff --git a/backend/schemas.py b/backend/schemas.py new file mode 100644 index 0000000..16e4e21 --- /dev/null +++ b/backend/schemas.py @@ -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) diff --git a/backend/scripts/__init__.py b/backend/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/scripts/dictionary_importer.py b/backend/scripts/dictionary_importer.py new file mode 100644 index 0000000..b9da61d --- /dev/null +++ b/backend/scripts/dictionary_importer.py @@ -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 diff --git a/backend/scripts/import_dictionary.py b/backend/scripts/import_dictionary.py new file mode 100644 index 0000000..6a5c40e --- /dev/null +++ b/backend/scripts/import_dictionary.py @@ -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() diff --git a/backend/scripts/import_dictionary_online.py b/backend/scripts/import_dictionary_online.py new file mode 100644 index 0000000..fbed170 --- /dev/null +++ b/backend/scripts/import_dictionary_online.py @@ -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() diff --git a/backend/scripts/init_mysql.sql b/backend/scripts/init_mysql.sql new file mode 100644 index 0000000..59f7549 --- /dev/null +++ b/backend/scripts/init_mysql.sql @@ -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; diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/dictionary_service.py b/backend/services/dictionary_service.py new file mode 100644 index 0000000..0308917 --- /dev/null +++ b/backend/services/dictionary_service.py @@ -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() diff --git a/backend/services/quiz_service.py b/backend/services/quiz_service.py new file mode 100644 index 0000000..970589f --- /dev/null +++ b/backend/services/quiz_service.py @@ -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() diff --git a/backend/services/translation_service.py b/backend/services/translation_service.py new file mode 100644 index 0000000..0004c01 --- /dev/null +++ b/backend/services/translation_service.py @@ -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() diff --git a/backend/services/word_service.py b/backend/services/word_service.py new file mode 100644 index 0000000..d91b22a --- /dev/null +++ b/backend/services/word_service.py @@ -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() diff --git a/deploy/DEPLOY.md b/deploy/DEPLOY.md new file mode 100644 index 0000000..b337f76 --- /dev/null +++ b/deploy/DEPLOY.md @@ -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` — 前端生产构建 diff --git a/deploy/build.sh b/deploy/build.sh new file mode 100755 index 0000000..e12e6aa --- /dev/null +++ b/deploy/build.sh @@ -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/" diff --git a/deploy/cloudflared/config.yml.example b/deploy/cloudflared/config.yml.example new file mode 100644 index 0000000..314f6c7 --- /dev/null +++ b/deploy/cloudflared/config.yml.example @@ -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/.json) +# 4. 在 Cloudflare 控制台为 w.tkmind.cn 添加 Public Hostname,或执行: +# cloudflared tunnel route dns wordloop w.tkmind.cn + +tunnel: wordloop +credentials-file: /path/to/.cloudflared/.json + +ingress: + - hostname: w.tkmind.cn + service: http://127.0.0.1:8080 + - service: http_status:404 diff --git a/deploy/install-production.sh b/deploy/install-production.sh new file mode 100755 index 0000000..44f843d --- /dev/null +++ b/deploy/install-production.sh @@ -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" <>> 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" diff --git a/deploy/nginx-production.conf b/deploy/nginx-production.conf new file mode 100644 index 0000000..33af385 --- /dev/null +++ b/deploy/nginx-production.conf @@ -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; +} diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..775c394 --- /dev/null +++ b/deploy/nginx.conf @@ -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; + } +} diff --git a/deploy/publish.sh b/deploy/publish.sh new file mode 100755 index 0000000..a839c5b --- /dev/null +++ b/deploy/publish.sh @@ -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)" diff --git a/deploy/secrets.env.example b/deploy/secrets.env.example new file mode 100644 index 0000000..c9d52cd --- /dev/null +++ b/deploy/secrets.env.example @@ -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 diff --git a/deploy/setup-tunnel.sh b/deploy/setup-tunnel.sh new file mode 100755 index 0000000..e334aa4 --- /dev/null +++ b/deploy/setup-tunnel.sh @@ -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/.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" diff --git a/deploy/ssl-w.tkmind.cn.sh b/deploy/ssl-w.tkmind.cn.sh new file mode 100644 index 0000000..4595e83 --- /dev/null +++ b/deploy/ssl-w.tkmind.cn.sh @@ -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}/" diff --git a/deploy/sync-db-to-production.sh b/deploy/sync-db-to-production.sh new file mode 100755 index 0000000..6ae18ca --- /dev/null +++ b/deploy/sync-db-to-production.sh @@ -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 ">>> 同步完成" diff --git a/deploy/systemd/cloudflared-wordloop.service b/deploy/systemd/cloudflared-wordloop.service new file mode 100644 index 0000000..8c61e17 --- /dev/null +++ b/deploy/systemd/cloudflared-wordloop.service @@ -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 diff --git a/deploy/systemd/wordloop-backend-root.service b/deploy/systemd/wordloop-backend-root.service new file mode 100644 index 0000000..c53e285 --- /dev/null +++ b/deploy/systemd/wordloop-backend-root.service @@ -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 diff --git a/deploy/systemd/wordloop-backend.service b/deploy/systemd/wordloop-backend.service new file mode 100644 index 0000000..38bc44a --- /dev/null +++ b/deploy/systemd/wordloop-backend.service @@ -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 diff --git a/deploy/wordloop-locations.inc b/deploy/wordloop-locations.inc new file mode 100644 index 0000000..719e2ef --- /dev/null +++ b/deploy/wordloop-locations.inc @@ -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"; +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..6c22815 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + WordLoop 单词循环记忆 + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..de5b6d8 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1893 @@ +{ + "name": "wordloop-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wordloop-frontend", + "version": "1.0.0", + "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" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.35.tgz", + "integrity": "sha512-BUmHaR1J+O+CKZ9uJucdVTEr1LHsdyvv7vG3eNRhK3CczEHeMd/LtsHAuD7PbrxvI2envCY2v7HI1vC1aBRzKw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/shared": "3.5.35", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.35.tgz", + "integrity": "sha512-k+bprkXxuqhVajgTx5mUHuir7TwQzUKOWR40ng1ncAqQRPnrLngGGgqVEEhOnTMlc8btHYVKmrP8s5Qyg0hvYA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.35", + "@vue/shared": "3.5.35" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.35.tgz", + "integrity": "sha512-G5VPMcXTSywXBgtFOZOnHKBxKSrwXUcvY1iaF5/hRcy7t0J6CH/d8ha9F4nzi00Fax1eLV0QHM7v4mQu68jydw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/compiler-core": "3.5.35", + "@vue/compiler-dom": "3.5.35", + "@vue/compiler-ssr": "3.5.35", + "@vue/shared": "3.5.35", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.35.tgz", + "integrity": "sha512-rGhAeXgdM7/ffTJGXT69rCCdTmjDewnFuUZfBQQHTdcEBeWdT5HCGY60y2ytLJr9/Dsu7IntUi5z/w0h6Rjnzw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.35", + "@vue/shared": "3.5.35" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.35.tgz", + "integrity": "sha512-tVc+SsHConvh/Lz64qq1pP3rYArBmK42xonovEcxY74SQtvctZodG/zhq54P5dr38cVuw25d27cPNRdlMidpGQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.35" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.35.tgz", + "integrity": "sha512-A/xFNX9loIcWDygeQuNCfKuh0CoYBzxhqEMNah5TSFg9Z53DrFYEN2qi5CU9necjM1OWYegYREUTHmXTmhfXtg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.35", + "@vue/shared": "3.5.35" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.35.tgz", + "integrity": "sha512-odrJ1C391dbGnyDRh8U+rnP7J2amIEzfmRk5vXy7xi3aZhEXofTvpi0T4HJb6jlNqQZTNPR5MPHSB3RHNkIORA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.35", + "@vue/runtime-core": "3.5.35", + "@vue/shared": "3.5.35", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.35.tgz", + "integrity": "sha512-NkebSOYdB97wi8OQcO3HqzZSlymJi/aWsN/7h74OSVhRTm6qGs3Jp3e0rCXynmWwSlKeRrnlIug+ilYoHBmQDA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.35", + "@vue/shared": "3.5.35" + }, + "peerDependencies": { + "vue": "3.5.35" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.35.tgz", + "integrity": "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", + "integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/rollup": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.35.tgz", + "integrity": "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.35", + "@vue/compiler-sfc": "3.5.35", + "@vue/runtime-dom": "3.5.35", + "@vue/server-renderer": "3.5.35", + "@vue/shared": "3.5.35" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..f14d91f --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..98240ae --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,3 @@ + diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts new file mode 100644 index 0000000..9f6a101 --- /dev/null +++ b/frontend/src/api/request.ts @@ -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('/translate', { text }), + createWord: (data: Partial) => request.post('/words', data), + listWords: (status?: string) => + request.get('/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('/quiz/stats'), + getSettings: () => request.get('/settings'), + updateSettings: (data: Partial) => request.patch('/settings', data), +} diff --git a/frontend/src/components/QuizCard.vue b/frontend/src/components/QuizCard.vue new file mode 100644 index 0000000..1712892 --- /dev/null +++ b/frontend/src/components/QuizCard.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/frontend/src/components/WordCard.vue b/frontend/src/components/WordCard.vue new file mode 100644 index 0000000..986a9cb --- /dev/null +++ b/frontend/src/components/WordCard.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue new file mode 100644 index 0000000..55dba37 --- /dev/null +++ b/frontend/src/layouts/MainLayout.vue @@ -0,0 +1,27 @@ + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..cd69284 --- /dev/null +++ b/frontend/src/main.ts @@ -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') diff --git a/frontend/src/pages/DailyQuiz.vue b/frontend/src/pages/DailyQuiz.vue new file mode 100644 index 0000000..8f8f486 --- /dev/null +++ b/frontend/src/pages/DailyQuiz.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/frontend/src/pages/Dashboard.vue b/frontend/src/pages/Dashboard.vue new file mode 100644 index 0000000..6f8dece --- /dev/null +++ b/frontend/src/pages/Dashboard.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/frontend/src/pages/Login.vue b/frontend/src/pages/Login.vue new file mode 100644 index 0000000..c1915d6 --- /dev/null +++ b/frontend/src/pages/Login.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/frontend/src/pages/Register.vue b/frontend/src/pages/Register.vue new file mode 100644 index 0000000..bb614cc --- /dev/null +++ b/frontend/src/pages/Register.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/frontend/src/pages/Settings.vue b/frontend/src/pages/Settings.vue new file mode 100644 index 0000000..ad25e32 --- /dev/null +++ b/frontend/src/pages/Settings.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/frontend/src/pages/Translate.vue b/frontend/src/pages/Translate.vue new file mode 100644 index 0000000..47c2b2a --- /dev/null +++ b/frontend/src/pages/Translate.vue @@ -0,0 +1,124 @@ + + +