feat: 1st ver

This commit is contained in:
KirisameVanilla 2025-08-19 16:05:55 +08:00 committed by KirisameVanilla
parent 79a950ca68
commit d25f7447ac
23 changed files with 1586 additions and 89 deletions

149
README.md
View File

@ -1,5 +1,148 @@
# Vue 3 + TypeScript + Vite
# Vue Dicts - 法语词典学习平台
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
一个基于 Vue 3 + TypeScript + Tailwind CSS 的现代化法语学习平台,集成词典查询、翻译和写作指导功能。
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
## 功能特性
- 🔍 **智能词典查询** - 支持法语/英语词汇查询,提供详细释义和例句
- 🌐 **多语言翻译** - 中法英三语互译,支持翻译历史记录
- ✍️ **AI写作指导** - 智能分析法语作文,提供语法和文体建议
- 👤 **用户认证系统** - JWT token认证安全的用户登录注册
- 📱 **响应式设计** - 适配多种设备尺寸
- 🎨 **优雅UI设计** - 基于Tailwind CSS的现代化界面
## 技术栈
### 前端
- Vue 3 (Composition API)
- TypeScript
- Vue Router 4
- Tailwind CSS
- Axios
- @vueuse/core
### 后端
- FastAPI (Python)
- Tortoise ORM
- Redis
- JWT Authentication
- MySQL/SQLite
## 快速开始
### 环境要求
- Node.js 16+
- Python 3.8+
- Redis
- MySQL (可选默认使用SQLite)
### 安装依赖
1. **前端依赖**
```bash
npm install
```
2. **后端依赖**
```bash
cd backend/dict-server
pip install -r requirements.txt
```
### 启动项目
#### 方法一使用批处理文件Windows
```bash
# 启动后端
start-backend.bat
# 启动前端(新开命令窗口)
start-frontend.bat
```
#### 方法二:手动启动
```bash
# 启动后端
cd backend/dict-server
python main.py
# 启动前端(新开终端)
npm run dev
```
访问 http://localhost:5173 查看前端界面
后端API文档http://localhost:8000/docs
## 项目结构
```
vue-dicts/
├── src/ # 前端源码
│ ├── api/ # API客户端
│ ├── components/ # Vue组件
│ ├── composables/ # 组合式API
│ ├── router/ # 路由配置
│ ├── views/ # 页面组件
│ └── style.css # 全局样式
├── backend/ # 后端源码
│ └── dict-server/ # FastAPI应用
├── models/ # 设计效果图
├── public/ # 静态资源
└── package.json # 项目配置
```
## 主要页面
- **首页** (`/`) - 项目介绍和快速入口
- **词典查询** (`/dict`) - 法语词汇查询功能
- **翻译** (`/trans`) - 多语言翻译功能
- **写作指导** (`/write`) - AI写作辅助功能
- **登录** (`/login`) - 用户认证页面
## API接口
### 用户认证
- `POST /users/login` - 用户登录
- `POST /users/register` - 用户注册
- `POST /users/logout` - 用户登出
### 词典查询
- `GET /search` - 搜索单词
- 参数: `lang_pref` (语言偏好), `query_word` (查询词汇)
## 开发说明
### 前端开发
- 使用 Vue 3 Composition API
- TypeScript 提供类型安全
- Tailwind CSS 用于样式
- 响应式设计适配移动端
### 后端开发
- FastAPI 提供高性能API
- Tortoise ORM 数据库操作
- JWT 认证保护接口
- Redis 缓存提升性能
## 部署
### 前端部署
```bash
npm run build
# 将 dist/ 目录部署到静态服务器
```
### 后端部署
```bash
cd backend/dict-server
# 配置生产环境变量
# 启动 uvicorn 服务器
```
## 贡献
欢迎提交 Issue 和 Pull Request 来改进项目!
## 许可证
MIT License

BIN
models/写作指导.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
models/查词效果.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

BIN
models/登录.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

BIN
models/翻译.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

BIN
models/首页.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 566 KiB

325
package-lock.json generated
View File

@ -9,6 +9,8 @@
"version": "0.0.0",
"dependencies": {
"@tailwindcss/vite": "^4.1.11",
"@vueuse/core": "^13.7.0",
"axios": "^1.11.0",
"tailwindcss": "^4.1.11",
"vue": "^3.5.17",
"vue-router": "^4.5.1"
@ -1078,6 +1080,12 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"license": "MIT"
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.0.tgz",
@ -1285,6 +1293,44 @@
}
}
},
"node_modules/@vueuse/core": {
"version": "13.7.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.7.0.tgz",
"integrity": "sha512-myagn09+c6BmS6yHc1gTwwsdZilAovHslMjyykmZH3JNyzI5HoWhv114IIdytXiPipdHJ2gDUx0PB93jRduJYg==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "13.7.0",
"@vueuse/shared": "13.7.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/metadata": {
"version": "13.7.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.7.0.tgz",
"integrity": "sha512-8okFhS/1ite8EwUdZZfvTYowNTfXmVCOrBFlA31O0HD8HKXhY+WtTRyF0LwbpJfoFPc+s9anNJIXMVrvP7UTZg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "13.7.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.7.0.tgz",
"integrity": "sha512-Wi2LpJi4UA9kM0OZ0FCZslACp92HlVNw1KPaDY6RAzvQ+J1s7seOtcOpmkfbD5aBSmMn9NvOakc8ZxMxmDXTIg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/alien-signals": {
"version": "1.0.13",
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz",
@ -1292,6 +1338,23 @@
"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.11.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
"integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -1309,6 +1372,19 @@
"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/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
@ -1318,6 +1394,18 @@
"node": ">=18"
}
},
"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.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@ -1331,6 +1419,15 @@
"dev": true,
"license": "MIT"
},
"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/detect-libc": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
@ -1340,6 +1437,20 @@
"node": ">=8"
}
},
"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/enhanced-resolve": {
"version": "5.18.2",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz",
@ -1365,6 +1476,51 @@
"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.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"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.6",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.6.tgz",
@ -1426,6 +1582,42 @@
}
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"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.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"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",
@ -1440,12 +1632,109 @@
"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/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"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.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"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",
@ -1702,6 +1991,36 @@
"@jridgewell/sourcemap-codec": "^1.5.0"
}
},
"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.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
@ -1832,6 +2151,12 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/rollup": {
"version": "4.45.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.45.1.tgz",

View File

@ -10,6 +10,8 @@
},
"dependencies": {
"@tailwindcss/vite": "^4.1.11",
"@vueuse/core": "^13.7.0",
"axios": "^1.11.0",
"tailwindcss": "^4.1.11",
"vue": "^3.5.17",
"vue-router": "^4.5.1"

53
src/api/auth.ts Normal file
View File

@ -0,0 +1,53 @@
import apiClient from './client'
export interface LoginRequest {
name: string
password: string
}
export interface RegisterRequest {
username: string
password: string
lang_pref: 'jp' | 'fr' | 'private'
portrait?: string
}
export interface LoginResponse {
access_token: string
token_type: string
user: {
id: number
username: string
is_admin: boolean
}
}
export interface RegisterResponse {
id: number
message: string
}
// 用户登录
export const login = async (credentials: LoginRequest): Promise<LoginResponse> => {
const response = await apiClient.post('/users/login', credentials)
return response.data
}
// 用户注册
export const register = async (userData: RegisterRequest): Promise<RegisterResponse> => {
const response = await apiClient.post('/users/register', userData)
return response.data
}
// 用户登出
export const logout = async (): Promise<void> => {
await apiClient.post('/users/logout')
localStorage.removeItem('access_token')
localStorage.removeItem('user')
}
// 获取当前用户信息
export const getCurrentUser = async () => {
const response = await apiClient.get('/users/me')
return response.data
}

42
src/api/client.ts Normal file
View File

@ -0,0 +1,42 @@
import axios from 'axios'
// 创建axios实例
const apiClient = axios.create({
baseURL: 'http://localhost:8000', // 后端API地址
timeout: 10000,
headers: {
'Content-Type': 'application/json',
}
})
// 请求拦截器 - 添加token
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器 - 处理错误
apiClient.interceptors.response.use(
(response) => {
return response
},
(error) => {
if (error.response?.status === 401) {
// token过期或无效清除本地存储并跳转到登录页
localStorage.removeItem('access_token')
localStorage.removeItem('user')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export default apiClient

24
src/api/dict.ts Normal file
View File

@ -0,0 +1,24 @@
import apiClient from './client'
export interface WordDefinition {
word: string
part_of_speech: string
meaning: string
example: string
}
export interface SearchParams {
lang_pref: string
query_word: string
}
// 搜索单词
export const searchWord = async (params: SearchParams): Promise<WordDefinition[]> => {
const response = await apiClient.get('/search', {
params: {
lang_pref: params.lang_pref,
query_word: params.query_word
}
})
return response.data
}

View File

@ -1,13 +1,13 @@
<template>
<header class="bg-white h-[174px] flex items-center">
<div class="max-w-[1030px] mx-auto flex items-center w-full">
<header class="flex items-center bg-white h-[174px]">
<div class="flex items-center mx-auto w-full max-w-[1030px]">
<!-- Logo -->
<router-link to="/" class="block w-[237px] h-[61px] bg-[url('/images/LOGO.png')] bg-contain bg-no-repeat pl-[100px] leading-[60px] font-deserta text-5xl text-blue-700">
<router-link to="/" class="block bg-[url('/images/LOGO.png')] bg-contain bg-no-repeat pl-[100px] w-[237px] h-[61px] font-deserta text-blue-700 text-5xl leading-[60px]">
CiDian
</router-link>
<!-- Nav -->
<nav class="ml-[187px] flex space-x-[53px] text-xl">
<nav class="flex space-x-[53px] ml-[187px] text-xl">
<router-link v-slot="{ isActive }" to="/dict">
<a
:class="[
@ -45,13 +45,28 @@
</nav>
<!-- User -->
<div class="flex space-x-4 ml-auto">
<a href="#" class="w-[42px] h-[42px] bg-gray-300 rounded-full border-2 border-primary block" />
<a href="#" class="w-[25px] h-[32px] bg-[url('/images/bell.png')] bg-cover" />
<a href="#" class="w-[42px] h-[42px] bg-[url('/images/icon_任务栏收缩.png')] bg-cover" />
<div class="flex items-center space-x-4 ml-auto">
<template v-if="isAuthenticated">
<span class="font-inter text-gray-700">{{ user?.username }}</span>
<a href="#" class="block bg-gray-300 border-2 border-blue-700 rounded-full w-[42px] h-[42px]" />
<a href="#" class="bg-[url('/images/bell.png')] bg-cover w-[25px] h-[32px]" />
<button @click="handleLogout" class="bg-[url('/images/icon_任务栏收缩.png')] bg-cover w-[42px] h-[42px]" />
</template>
<template v-else>
<router-link to="/login" class="bg-blue-700 hover:bg-blue-600 px-6 py-2 rounded-full font-inter text-white">
登录
</router-link>
</template>
</div>
</div>
</header>
</template>
<script setup lang="ts">
import { useAuth } from '../composables/useAuth'
const { user, isAuthenticated, logout } = useAuth()
const handleLogout = async () => {
await logout()
}
</script>

View File

@ -1,15 +1,46 @@
<template>
<section class="bg-white h-[88px] flex justify-center items-center">
<section class="flex justify-center items-center bg-white h-[88px]">
<div class="relative w-[804px]">
<span class="absolute left-0 top-1/2 -translate-y-1/2 bg-primary bg-blue-700 text-white rounded-full px-8 py-3 z-10">
/
</span>
<select
v-model="selectedLang"
class="top-1/2 left-0 z-10 absolute bg-blue-700 px-8 py-3 border-none rounded-full outline-none text-white -translate-y-1/2 appearance-none cursor-pointer"
>
<option value="fr">法语</option>
<option value="en">英语</option>
</select>
<input
v-model="searchQuery"
@keyup.enter="handleSearch"
type="text"
placeholder="请输入单词"
class="w-full h-[56px] pl-[207px] pr-[70px] border-[5px] border-primary border-blue-700 rounded-full text-xl outline-none"
class="pr-[70px] pl-[207px] border-[5px] border-blue-700 focus:border-blue-500 rounded-full outline-none w-full h-[56px] text-xl"
/>
<button
@click="handleSearch"
class="top-1/2 right-0 absolute bg-[length:60%] bg-[url('/images/search.png')] bg-blue-700 hover:bg-blue-600 bg-no-repeat bg-center rounded-full w-[56px] h-[56px] -translate-y-1/2"
/>
<button class="absolute right-0 top-1/2 -translate-y-1/2 w-[56px] h-[56px] bg-primary rounded-full bg-[url('/images/search.png')] bg-no-repeat bg-center bg-[length:60%]" />
</div>
</section>
</template>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const searchQuery = ref('')
const selectedLang = ref('fr')
const handleSearch = () => {
if (searchQuery.value.trim()) {
//
router.push({
name: 'Dict',
query: {
q: searchQuery.value.trim(),
lang: selectedLang.value
}
})
}
}
</script>

View File

@ -0,0 +1,88 @@
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { login as apiLogin, logout as apiLogout, register as apiRegister, type LoginRequest, type RegisterRequest } from '../api/auth'
// 全局状态
const user = ref<any>(null)
const token = ref<string | null>(null)
// 从localStorage恢复状态
const initAuth = () => {
const savedToken = localStorage.getItem('access_token')
const savedUser = localStorage.getItem('user')
if (savedToken) {
token.value = savedToken
}
if (savedUser) {
try {
user.value = JSON.parse(savedUser)
} catch (e) {
console.error('Failed to parse saved user:', e)
localStorage.removeItem('user')
}
}
}
export const useAuth = () => {
const router = useRouter()
const isAuthenticated = computed(() => !!token.value && !!user.value)
const login = async (credentials: LoginRequest) => {
try {
const response = await apiLogin(credentials)
// 保存到状态
token.value = response.access_token
user.value = response.user
// 保存到localStorage
localStorage.setItem('access_token', response.access_token)
localStorage.setItem('user', JSON.stringify(response.user))
return response
} catch (error) {
console.error('Login failed:', error)
throw error
}
}
const register = async (userData: RegisterRequest) => {
try {
const response = await apiRegister(userData)
return response
} catch (error) {
console.error('Registration failed:', error)
throw error
}
}
const logout = async () => {
try {
await apiLogout()
} catch (error) {
console.error('Logout error:', error)
} finally {
// 无论API调用是否成功都清除本地状态
token.value = null
user.value = null
localStorage.removeItem('access_token')
localStorage.removeItem('user')
router.push('/login')
}
}
return {
user: computed(() => user.value),
token: computed(() => token.value),
isAuthenticated,
login,
register,
logout
}
}
// 初始化认证状态
initAuth()

View File

@ -1,10 +1,30 @@
import { createRouter, createWebHistory } from 'vue-router'
export default createRouter({
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'Home', component: () => import('../views/HomePage.vue') },
{ path: '/dict', name: 'Dict', component: () => import('../views/DictPage.vue') },
{ path: '/trans', name: 'Translation', component: () => import('../views/TranslationPage.vue') },
{ path: '/write', name: 'Writing', component: () => import('../views/WritingPage.vue') },
{ path: '/login', name: 'Login', component: () => import('../views/LoginPage.vue') },
],
})
})
// 路由守卫
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('access_token')
// 需要认证的页面
const requiresAuth = ['Dict', 'Translation', 'Writing']
if (requiresAuth.includes(to.name as string) && !token) {
next('/login')
} else if (to.name === 'Login' && token) {
next('/')
} else {
next()
}
})
export default router

View File

@ -1,50 +1,84 @@
<template>
<div>
<AppHeader active="dict" />
<SearchBar />
<AiAssist />
<!-- upper -->
<section class="bg-[url('/images/french-learning-illustration.png')] bg-no-repeat bg-center pt-[95px]">
<div class="max-w-[1030px] mx-auto px-4">
<ul class="font-deserta text-[56px] leading-[76px]">
<li>YOUR ELEGANT</li>
<li class="text-primary">COMPANION</li>
<li>IN FRENCH LEARNING</li>
</ul>
<router-link to="/dict" class="inline-block mt-[67px] w-[268px] h-[64px] bg-primary bg-blue-700 text-white text-2xl rounded-full text-center leading-[64px]">
SEARCH NOW
</router-link>
<div class="mt-[139px]">
<h3 class="text-[40px] font-deserta">INTRODUCTION</h3>
<p class="mt-[59px] text-xl leading-8">
本站致力于打造一款高质量实用而优雅的法语词典工具集词义查询例句拓展词根分析于一体助力学习者深入理解每一个法语单词背后的文化与语境不止查词更是探索语言之美的旅程
</p>
<p class="mt-4 text-xl leading-8 text-gray-500">
A refined and practical French dictionary designed for learners and enthusiasts. Discover accurate definitions, contextual examples, and deep linguistic insights because learning French is more than memorizing words, it's exploring a world of meaning.
</p>
<!-- 搜索区域 -->
<section class="bg-white py-12">
<div class="mx-auto px-4 max-w-[1030px]">
<div class="mb-8 text-center">
<h1 class="mb-4 font-deserta text-blue-700 text-4xl">法语词典查询</h1>
<p class="font-inter text-gray-600">输入法语单词获取详细释义和例句</p>
</div>
<div class="mx-auto max-w-[600px]">
<div class="flex gap-4">
<input
v-model="searchQuery"
@keyup.enter="handleSearch"
type="text"
placeholder="请输入法语单词..."
class="flex-1 px-6 border-2 border-blue-700 focus:border-blue-500 rounded-full outline-none h-[60px] text-xl"
/>
<button
@click="handleSearch"
:disabled="loading || !searchQuery.trim()"
class="bg-blue-700 hover:bg-blue-600 disabled:opacity-50 rounded-full w-[120px] h-[60px] font-inter text-white disabled:cursor-not-allowed"
>
{{ loading ? '搜索中...' : '搜索' }}
</button>
</div>
<div class="flex justify-center mt-4">
<select v-model="selectedLang" class="px-4 py-2 border border-gray-300 rounded">
<option value="fr">法语</option>
<option value="en">英语</option>
</select>
</div>
</div>
</div>
</section>
<!-- lower -->
<section class="bg-[url('/images/lower.png')] bg-contain bg-no-repeat h-[437px] w-full">
<div class="max-w-[1030px] mx-auto flex items-center justify-between h-full">
<ul class="font-deserta text-[35px] leading-[53px]">
<li>CUMULATIVE <span class="text-primary">WORD</span></li>
<li>LOOKUP COUNT</li>
</ul>
<div>
<p class="text-muted mb-3 text-lg">累计</p>
<div class="flex items-end gap-2">
<span v-for="i in 11" :key="i" class="w-[34px] h-[60px] rounded-[17px] bg-gradient-to-b from-white to-[#d5e2f8] border border-transparent flex items-center justify-center text-2xl text-muted shadow">
0
</span>
<span class="text-primary text-[35px] ml-2">TIMES</span>
<!-- 搜索结果 -->
<section class="bg-gray-50 py-12 min-h-[400px]">
<div class="mx-auto px-4 max-w-[1030px]">
<div v-if="error" class="bg-red-100 mb-6 px-4 py-3 border border-red-400 rounded text-red-700">
{{ error }}
</div>
<div v-if="searchResults.length > 0" class="space-y-6">
<h2 class="mb-6 font-deserta text-blue-700 text-2xl">搜索结果</h2>
<div v-for="(result, index) in searchResults" :key="index" class="bg-white shadow-md p-6 rounded-lg">
<div class="flex justify-between items-start mb-4">
<h3 class="font-bold text-blue-700 text-2xl">{{ result.word }}</h3>
<span class="bg-blue-100 px-3 py-1 rounded-full text-blue-700 text-sm">
{{ result.part_of_speech }}
</span>
</div>
<div class="mb-4">
<h4 class="mb-2 font-semibold text-gray-700">释义</h4>
<p class="text-gray-800 leading-relaxed">{{ result.meaning }}</p>
</div>
<div v-if="result.example">
<h4 class="mb-2 font-semibold text-gray-700">例句</h4>
<p class="bg-gray-50 p-3 rounded text-gray-600 italic">{{ result.example }}</p>
</div>
</div>
</div>
<div v-else-if="!loading && hasSearched" class="py-12 text-gray-500 text-center">
<div class="mb-4 text-6xl">📚</div>
<p class="text-xl">未找到相关词汇</p>
<p class="mt-2 text-gray-400">请尝试其他关键词</p>
</div>
<div v-else-if="!hasSearched" class="py-12 text-gray-400 text-center">
<div class="mb-4 text-6xl">🔍</div>
<p class="text-xl">开始您的法语词汇探索之旅</p>
<p class="mt-2">在上方输入框中输入法语单词进行查询</p>
</div>
</div>
</section>
@ -52,9 +86,58 @@
</div>
</template>
<script setup>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import AppHeader from '../components/AppHeader.vue'
import SearchBar from '../components/SearchBar.vue'
import AiAssist from '../components/AiAssist.vue'
import AppFooter from '../components/AppFooter.vue'
import { searchWord, type WordDefinition } from '../api/dict'
const route = useRoute()
const searchQuery = ref('')
const selectedLang = ref('fr')
const searchResults = ref<WordDefinition[]>([])
const loading = ref(false)
const error = ref('')
const hasSearched = ref(false)
//
onMounted(() => {
const q = route.query.q as string
const lang = route.query.lang as string
if (q) {
searchQuery.value = q
}
if (lang) {
selectedLang.value = lang
}
//
if (q) {
handleSearch()
}
})
const handleSearch = async () => {
if (!searchQuery.value.trim()) return
loading.value = true
error.value = ''
hasSearched.value = true
try {
const results = await searchWord({
lang_pref: selectedLang.value,
query_word: searchQuery.value.trim()
})
searchResults.value = results
} catch (err: any) {
console.error('Search error:', err)
error.value = err.response?.data?.detail || '搜索失败,请稍后重试'
searchResults.value = []
} finally {
loading.value = false
}
}
</script>

View File

@ -1,27 +1,27 @@
<template>
<div>
<AppHeader active="dict" />
<AppHeader />
<SearchBar />
<AiAssist />
<!-- upper -->
<section class="bg-[url('/images/french-learning-illustration.png')] bg-no-repeat bg-center pt-[95px]">
<div class="max-w-[1030px] mx-auto px-4">
<div class="mx-auto px-4 max-w-[1030px]">
<ul class="font-deserta text-[56px] leading-[76px]">
<li>YOUR ELEGANT</li>
<li class="text-blue-700">COMPANION</li>
<li>IN FRENCH LEARNING</li>
</ul>
<router-link to="/dict" class="font-deserta inline-block mt-[67px] w-[268px] h-[64px] bg-primary bg-blue-700 text-white text-2xl rounded-full text-center leading-[64px]">
<router-link to="/dict" class="inline-block bg-blue-700 bg-primary mt-[67px] rounded-full w-[268px] h-[64px] font-deserta text-white text-2xl text-center leading-[64px]">
SEARCH NOW
</router-link>
<div class="mt-[139px]">
<h3 class="text-[40px] font-deserta">INTRODUCTION</h3>
<p class="font-inter mt-[59px] text-xl leading-8">
<h3 class="font-deserta text-[40px]">INTRODUCTION</h3>
<p class="mt-[59px] font-inter text-xl leading-8">
本站致力于打造一款高质量实用而优雅的法语词典工具集词义查询例句拓展词根分析于一体助力学习者深入理解每一个法语单词背后的文化与语境不止查词更是探索语言之美的旅程
</p>
<p class="font-serif mt-4 text-xl leading-8 text-gray-500">
<p class="mt-4 font-serif text-gray-500 text-xl leading-8">
A refined and practical French dictionary designed for learners and enthusiasts. Discover accurate definitions, contextual examples, and deep linguistic insights because learning French is more than memorizing words, it's exploring a world of meaning.
</p>
</div>
@ -29,20 +29,20 @@
</section>
<!-- lower -->
<section class="bg-[url('/images/lower.png')] bg-contain bg-no-repeat h-[437px] w-full">
<div class="max-w-[1030px] mx-auto flex items-center justify-between h-full">
<section class="bg-[url('/images/lower.png')] bg-contain bg-no-repeat w-full h-[437px]">
<div class="flex justify-between items-center mx-auto max-w-[1030px] h-full">
<ul class="font-deserta text-[35px] leading-[53px]">
<li>CUMULATIVE <span class="text-primary">WORD</span></li>
<li>LOOKUP COUNT</li>
</ul>
<div>
<p class="text-muted mb-3 text-lg">累计</p>
<p class="mb-3 text-muted text-lg">累计</p>
<div class="flex items-end gap-2">
<span v-for="i in 11" :key="i" class="w-[34px] h-[60px] rounded-[17px] bg-gradient-to-b from-white to-[#d5e2f8] border border-transparent flex items-center justify-center text-2xl text-muted shadow">
<span v-for="i in 11" :key="i" class="flex justify-center items-center bg-gradient-to-b from-white to-[#d5e2f8] shadow border border-transparent rounded-[17px] w-[34px] h-[60px] text-muted text-2xl">
0
</span>
<span class="font-deserta text-primary text-[35px] ml-2">TIMES</span>
<span class="ml-2 font-deserta text-[35px] text-primary">TIMES</span>
</div>
</div>
</div>

View File

@ -1,38 +1,229 @@
<template>
<div>
<header class="bg-white h-[174px] flex items-center">
<div class="max-w-[1030px] mx-auto">
<router-link to="/" class="block w-[237px] h-[61px] bg-[url('/images/LOGO.png')] bg-contain bg-no-repeat pl-[100px] leading-[31px] font-deserta text-2xl">
<header class="flex items-center bg-white h-[174px]">
<div class="mx-auto max-w-[1030px]">
<router-link to="/" class="block bg-[url('/images/LOGO.png')] bg-contain bg-no-repeat pl-[100px] w-[237px] h-[61px] font-deserta text-2xl leading-[31px]">
CiDian
</router-link>
</div>
</header>
<main class="relative overflow-visible">
<div class="max-w-[1030px] mx-auto pt-10">
<div class="mx-auto pt-10 max-w-[1030px]">
<ul class="flex flex-col items-end font-deserta text-5xl leading-[76px]">
<li class="font-inter text-lg text-muted">BONJOUR</li>
<li class="text-primary">WELCOME BACK</li>
<li class="font-inter text-gray-500 text-lg">BONJOUR</li>
<li class="text-blue-700">{{ isLogin ? 'WELCOME BACK' : 'JOIN US TODAY' }}</li>
<li>LET'S GET STARTED</li>
</ul>
<form class="mt-[77px] max-w-[401px]">
<label class="block mb-6 text-muted text-xl">手机号</label>
<input type="text" class="w-full h-[63px] border border-primary rounded-full px-8 text-2xl outline-none" />
<!-- 切换按钮 -->
<div class="flex bg-gray-100 mt-8 p-1 rounded-full w-fit">
<button
@click="isLogin = true"
:class="[
'px-6 py-2 rounded-full transition-all font-inter',
isLogin ? 'bg-blue-700 text-white' : 'text-gray-600 hover:text-blue-700'
]"
>
登录
</button>
<button
@click="isLogin = false"
:class="[
'px-6 py-2 rounded-full transition-all font-inter',
!isLogin ? 'bg-blue-700 text-white' : 'text-gray-600 hover:text-blue-700'
]"
>
注册
</button>
</div>
<label class="block mt-8 mb-6 text-muted text-xl">密码</label>
<input type="password" class="w-full h-[63px] border border-primary rounded-full px-8 text-2xl outline-none" />
<form @submit.prevent="isLogin ? handleLogin() : handleRegister()" class="mt-[77px] max-w-[401px]">
<div v-if="error" class="bg-red-100 mb-4 p-3 border border-red-400 rounded text-red-700">
{{ error }}
</div>
<div v-if="successMessage" class="bg-green-100 mb-4 p-3 border border-green-400 rounded text-green-700">
{{ successMessage }}
</div>
<label class="block mb-6 text-gray-500 text-xl">用户名</label>
<input
:value="isLogin ? loginForm.name : registerForm.username"
@input="handleUsernameInput"
type="text"
required
class="px-8 border border-blue-700 focus:border-blue-500 rounded-full outline-none w-full h-[63px] text-2xl"
/>
<button type="submit" class="mt-[72px] w-[268px] h-[63px] bg-primary text-white text-2xl rounded-full">
LOG IN
<label class="block mt-8 mb-6 text-gray-500 text-xl">密码</label>
<input
:value="isLogin ? loginForm.password : registerForm.password"
@input="handlePasswordInput"
type="password"
required
class="px-8 border border-blue-700 focus:border-blue-500 rounded-full outline-none w-full h-[63px] text-2xl"
/>
<!-- 注册时显示确认密码 -->
<template v-if="!isLogin">
<label class="block mt-8 mb-6 text-gray-500 text-xl">确认密码</label>
<input
v-model="confirmPassword"
type="password"
required
class="px-8 border border-blue-700 focus:border-blue-500 rounded-full outline-none w-full h-[63px] text-2xl"
/>
<label class="block mt-8 mb-6 text-gray-500 text-xl">语言偏好</label>
<select
v-model="registerForm.lang_pref"
class="bg-white px-8 border border-blue-700 focus:border-blue-500 rounded-full outline-none w-full h-[63px] text-2xl"
>
<option value="fr">法语</option>
<option value="jp">日语</option>
<option value="private">私人</option>
</select>
</template>
<button
type="submit"
:disabled="loading"
class="bg-blue-700 hover:bg-blue-600 disabled:opacity-50 mt-[72px] rounded-full w-[268px] h-[63px] text-white text-2xl disabled:cursor-not-allowed"
>
{{ loading ? (isLogin ? 'LOGGING IN...' : 'REGISTERING...') : (isLogin ? 'LOG IN' : 'REGISTER') }}
</button>
</form>
</div>
<!-- 背景图伪元素 -->
<div class="absolute top-4 -right-[200px] w-[1200px] h-[800px] bg-[url('/images/8ba41f...png')] bg-no-repeat bg-auto bg-[position:-100px_0] -z-10" />
<!-- 背景图 -->
<div class="top-4 -right-[200px] -z-10 absolute bg-[position:-100px_0] bg-[url('/images/9d1ecd03bf3f17a66e03547f54973cf5a254ac10a6b97a5bfb60098680db7160.png')] bg-auto bg-no-repeat w-[1200px] h-[800px]" />
</main>
<footer class="h-[465px] bg-gradient-to-t from-[#4284e8] to-transparent" />
<footer class="bg-gradient-to-t from-[#4284e8] to-transparent h-[465px]" />
</div>
</template>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuth } from '../composables/useAuth'
const router = useRouter()
const { login, register } = useAuth()
const isLogin = ref(true)
const loginForm = ref({
name: '',
password: ''
})
const registerForm = ref({
username: '',
password: '',
lang_pref: 'fr' as 'jp' | 'fr' | 'private'
})
const confirmPassword = ref('')
const loading = ref(false)
const error = ref('')
const successMessage = ref('')
//
const handleUsernameInput = (event: Event) => {
const target = event.target as HTMLInputElement
if (isLogin.value) {
loginForm.value.name = target.value
} else {
registerForm.value.username = target.value
}
}
//
const handlePasswordInput = (event: Event) => {
const target = event.target as HTMLInputElement
if (isLogin.value) {
loginForm.value.password = target.value
} else {
registerForm.value.password = target.value
}
}
const handleLogin = async () => {
if (!loginForm.value.name || !loginForm.value.password) {
error.value = '请填写用户名和密码'
return
}
loading.value = true
error.value = ''
try {
await login(loginForm.value)
router.push('/dict')
} catch (err: any) {
console.error('Login error:', err)
error.value = err.response?.data?.detail || '登录失败,请检查用户名和密码'
} finally {
loading.value = false
}
}
const handleRegister = async () => {
//
if (!registerForm.value.username || !registerForm.value.password) {
error.value = '请填写所有必填字段'
return
}
if (registerForm.value.password !== confirmPassword.value) {
error.value = '两次输入的密码不一致'
return
}
if (registerForm.value.password.length < 6) {
error.value = '密码长度至少6位'
return
}
loading.value = true
error.value = ''
successMessage.value = ''
try {
const username = registerForm.value.username //
await register(registerForm.value)
successMessage.value = '注册成功!请登录'
//
loginForm.value.name = username
//
isLogin.value = true
registerForm.value = {
username: '',
password: '',
lang_pref: 'fr'
}
confirmPassword.value = ''
} catch (err: any) {
console.error('Register error:', err)
error.value = err.response?.data?.detail || '注册失败,请稍后重试'
} finally {
loading.value = false
}
}
//
const clearMessages = () => {
error.value = ''
successMessage.value = ''
}
//
import { watch } from 'vue'
watch(isLogin, () => {
clearMessages()
})
</script>

View File

@ -0,0 +1,217 @@
<template>
<div>
<AppHeader />
<!-- 翻译区域 -->
<section class="bg-white py-12">
<div class="mx-auto px-4 max-w-[1030px]">
<div class="mb-8 text-center">
<h1 class="mb-4 font-deserta text-blue-700 text-4xl">法语翻译</h1>
<p class="font-inter text-gray-600">中法双向翻译助力语言学习</p>
</div>
<div class="mx-auto max-w-[900px]">
<div class="gap-6 grid grid-cols-1 md:grid-cols-2">
<!-- 输入区域 -->
<div class="bg-gray-50 p-6 rounded-lg">
<div class="flex justify-between items-center mb-4">
<h3 class="font-semibold text-gray-700">源语言</h3>
<select v-model="sourceLang" class="px-3 py-1 border border-gray-300 rounded">
<option value="zh">中文</option>
<option value="fr">法语</option>
<option value="en">英语</option>
</select>
</div>
<textarea
v-model="sourceText"
placeholder="请输入要翻译的文本..."
class="p-4 border border-gray-300 focus:border-blue-500 rounded-lg outline-none w-full h-[200px] resize-none"
></textarea>
</div>
<!-- 输出区域 -->
<div class="bg-blue-50 p-6 rounded-lg">
<div class="flex justify-between items-center mb-4">
<h3 class="font-semibold text-gray-700">目标语言</h3>
<select v-model="targetLang" class="px-3 py-1 border border-gray-300 rounded">
<option value="zh">中文</option>
<option value="fr">法语</option>
<option value="en">英语</option>
</select>
</div>
<div class="bg-white p-4 border border-gray-300 rounded-lg w-full h-[200px] overflow-y-auto">
<div v-if="loading" class="flex justify-center items-center h-full text-gray-500">
<div class="border-b-2 border-blue-700 rounded-full w-8 h-8 animate-spin"></div>
<span class="ml-2">翻译中...</span>
</div>
<div v-else-if="translationResult" class="text-gray-800 leading-relaxed">
{{ translationResult }}
</div>
<div v-else class="text-gray-400 italic">
翻译结果将在这里显示
</div>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex justify-center space-x-4 mt-6">
<button
@click="handleTranslate"
:disabled="loading || !sourceText.trim()"
class="bg-blue-700 hover:bg-blue-600 disabled:opacity-50 px-8 py-3 rounded-full font-inter text-white disabled:cursor-not-allowed"
>
{{ loading ? '翻译中...' : '翻译' }}
</button>
<button
@click="swapLanguages"
class="hover:bg-blue-50 px-8 py-3 border border-blue-700 rounded-full font-inter text-blue-700"
>
交换语言
</button>
<button
@click="clearText"
class="hover:bg-gray-50 px-8 py-3 border border-gray-300 rounded-full font-inter text-gray-700"
>
清空
</button>
</div>
<div v-if="error" class="bg-red-100 mt-4 px-4 py-3 border border-red-400 rounded text-red-700">
{{ error }}
</div>
</div>
</div>
</section>
<!-- 翻译历史 -->
<section class="bg-gray-50 py-12">
<div class="mx-auto px-4 max-w-[1030px]">
<h2 class="mb-6 font-deserta text-blue-700 text-2xl">翻译历史</h2>
<div v-if="translationHistory.length > 0" class="space-y-4">
<div v-for="(item, index) in translationHistory" :key="index" class="bg-white shadow-sm p-4 rounded-lg">
<div class="gap-4 grid grid-cols-1 md:grid-cols-2">
<div>
<p class="mb-1 text-gray-500 text-sm">{{ item.sourceLang === 'zh' ? '中文' : item.sourceLang === 'fr' ? '法语' : '英语' }}</p>
<p class="text-gray-800">{{ item.sourceText }}</p>
</div>
<div>
<p class="mb-1 text-gray-500 text-sm">{{ item.targetLang === 'zh' ? '中文' : item.targetLang === 'fr' ? '法语' : '英语' }}</p>
<p class="text-gray-800">{{ item.translationResult }}</p>
</div>
</div>
<p class="mt-2 text-gray-400 text-xs">{{ item.timestamp }}</p>
</div>
</div>
<div v-else class="py-12 text-gray-400 text-center">
<div class="mb-4 text-6xl">📝</div>
<p class="text-xl">暂无翻译历史</p>
<p class="mt-2">开始翻译以建立您的历史记录</p>
</div>
</div>
</section>
<AppFooter />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import AppHeader from '../components/AppHeader.vue'
import AppFooter from '../components/AppFooter.vue'
interface TranslationHistoryItem {
sourceText: string
translationResult: string
sourceLang: string
targetLang: string
timestamp: string
}
const sourceText = ref('')
const translationResult = ref('')
const sourceLang = ref('zh')
const targetLang = ref('fr')
const loading = ref(false)
const error = ref('')
const translationHistory = ref<TranslationHistoryItem[]>([])
// API
const handleTranslate = async () => {
if (!sourceText.value.trim()) return
loading.value = true
error.value = ''
try {
// API
// 使
await new Promise(resolve => setTimeout(resolve, 1000))
//
let result = ''
if (sourceLang.value === 'zh' && targetLang.value === 'fr') {
result = `[法语翻译] ${sourceText.value}`
} else if (sourceLang.value === 'fr' && targetLang.value === 'zh') {
result = `[中文翻译] ${sourceText.value}`
} else {
result = `[翻译结果] ${sourceText.value}`
}
translationResult.value = result
//
const historyItem: TranslationHistoryItem = {
sourceText: sourceText.value,
translationResult: result,
sourceLang: sourceLang.value,
targetLang: targetLang.value,
timestamp: new Date().toLocaleString('zh-CN')
}
translationHistory.value.unshift(historyItem)
// localStorage
localStorage.setItem('translationHistory', JSON.stringify(translationHistory.value.slice(0, 10))) // 10
} catch (err: any) {
console.error('Translation error:', err)
error.value = '翻译失败,请稍后重试'
} finally {
loading.value = false
}
}
const swapLanguages = () => {
const temp = sourceLang.value
sourceLang.value = targetLang.value
targetLang.value = temp
//
if (translationResult.value) {
const tempText = sourceText.value
sourceText.value = translationResult.value
translationResult.value = tempText
}
}
const clearText = () => {
sourceText.value = ''
translationResult.value = ''
error.value = ''
}
// localStorage
onMounted(() => {
const saved = localStorage.getItem('translationHistory')
if (saved) {
try {
translationHistory.value = JSON.parse(saved)
} catch (e) {
console.error('Failed to load translation history:', e)
}
}
})
</script>

234
src/views/WritingPage.vue Normal file
View File

@ -0,0 +1,234 @@
<template>
<div>
<AppHeader />
<!-- 写作指导主区域 -->
<section class="bg-white py-12">
<div class="mx-auto px-4 max-w-[1030px]">
<div class="mb-8 text-center">
<h1 class="mb-4 font-deserta text-blue-700 text-4xl">法语写作指导</h1>
<p class="font-inter text-gray-600">AI助力法语写作提升表达水平</p>
</div>
<div class="mx-auto max-w-[900px]">
<!-- 写作类型选择 -->
<div class="mb-6">
<h3 class="mb-3 font-semibold text-gray-700">写作类型</h3>
<div class="flex flex-wrap gap-3">
<button
v-for="type in writingTypes"
:key="type.value"
@click="selectedType = type.value"
:class="[
'px-4 py-2 rounded-full border transition-colors',
selectedType === type.value
? 'bg-blue-700 text-white border-blue-700'
: 'bg-white text-gray-700 border-gray-300 hover:border-blue-500'
]"
>
{{ type.label }}
</button>
</div>
</div>
<!-- 写作主题输入 -->
<div class="mb-6">
<h3 class="mb-3 font-semibold text-gray-700">写作主题或要求</h3>
<input
v-model="writingTopic"
type="text"
placeholder="请输入写作主题,如:我的假期、环境保护、法国文化等..."
class="px-4 border border-gray-300 focus:border-blue-500 rounded-lg outline-none w-full h-[50px]"
/>
</div>
<!-- 写作内容区域 -->
<div class="gap-6 grid grid-cols-1 lg:grid-cols-2 mb-6">
<!-- 写作区域 -->
<div class="bg-gray-50 p-6 rounded-lg">
<h3 class="mb-4 font-semibold text-gray-700">您的法语作文</h3>
<textarea
v-model="userText"
placeholder="请在这里写下您的法语作文..."
class="p-4 border border-gray-300 focus:border-blue-500 rounded-lg outline-none w-full h-[300px] resize-none"
></textarea>
<div class="flex justify-between items-center mt-2">
<span class="text-gray-500 text-sm">字数: {{ userText.length }}</span>
<button
@click="getWritingHelp"
:disabled="loading || !userText.trim()"
class="bg-blue-700 hover:bg-blue-600 disabled:opacity-50 px-4 py-2 rounded text-white disabled:cursor-not-allowed"
>
{{ loading ? '分析中...' : '获取指导' }}
</button>
</div>
</div>
<!-- 指导建议区域 -->
<div class="bg-blue-50 p-6 rounded-lg">
<h3 class="mb-4 font-semibold text-gray-700">AI写作指导</h3>
<div class="h-[300px] overflow-y-auto">
<div v-if="loading" class="flex justify-center items-center h-full text-gray-500">
<div class="border-b-2 border-blue-700 rounded-full w-8 h-8 animate-spin"></div>
<span class="ml-2">AI正在分析您的作文...</span>
</div>
<div v-else-if="writingFeedback" class="space-y-4">
<div v-for="(section, key) in writingFeedback" :key="key" class="bg-white p-4 rounded">
<h4 class="mb-2 font-semibold text-blue-700">{{ getSectionTitle(key) }}</h4>
<div v-if="Array.isArray(section)">
<ul class="space-y-1 list-disc list-inside">
<li v-for="(item, index) in section" :key="index" class="text-gray-700 text-sm">
{{ item }}
</li>
</ul>
</div>
<div v-else class="text-gray-700 text-sm">
{{ section }}
</div>
</div>
</div>
<div v-else class="flex justify-center items-center h-full text-gray-400 italic">
AI写作指导将在这里显示
</div>
</div>
</div>
</div>
<!-- 错误信息 -->
<div v-if="error" class="bg-red-100 mb-6 px-4 py-3 border border-red-400 rounded text-red-700">
{{ error }}
</div>
<!-- 快速写作模板 -->
<div class="bg-white p-6 border border-gray-200 rounded-lg">
<h3 class="mb-4 font-semibold text-gray-700">写作模板参考</h3>
<div class="gap-4 grid grid-cols-1 md:grid-cols-2">
<div v-for="template in writingTemplates" :key="template.type" class="p-4 border border-gray-200 rounded">
<h4 class="mb-2 font-semibold text-blue-700">{{ template.title }}</h4>
<p class="mb-3 text-gray-600 text-sm">{{ template.description }}</p>
<button
@click="useTemplate(template.template)"
class="text-blue-600 text-sm hover:underline"
>
使用此模板
</button>
</div>
</div>
</div>
</div>
</div>
</section>
<AppFooter />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import AppHeader from '../components/AppHeader.vue'
import AppFooter from '../components/AppFooter.vue'
const writingTypes = [
{ value: 'essay', label: '议论文' },
{ value: 'narrative', label: '记叙文' },
{ value: 'letter', label: '书信' },
{ value: 'diary', label: '日记' },
{ value: 'description', label: '描述文' },
{ value: 'report', label: '报告' }
]
const writingTemplates = [
{
type: 'essay',
title: '议论文模板',
description: '适用于表达观点和论证',
template: `Introduction:\nDe nos jours, [主题] est un sujet qui préoccupe beaucoup de gens...\n\nDéveloppement:\nD'une part, ...\nD'autre part, ...\n\nConclusion:\nEn conclusion, je pense que...`
},
{
type: 'letter',
title: '正式书信模板',
description: '适用于正式场合的书信写作',
template: `[Lieu], le [date]\n\nMonsieur/Madame,\n\nJ'ai l'honneur de vous écrire pour...\n\nJe vous prie d'agréer, Monsieur/Madame, l'expression de mes salutations distinguées.\n\n[Signature]`
},
{
type: 'narrative',
title: '记叙文模板',
description: '适用于叙述事件和经历',
template: `Il était une fois... / Un jour...\n\nTout d'abord...\nEnsuite...\nEnfin...\n\nCette expérience m'a appris que...`
},
{
type: 'description',
title: '描述文模板',
description: '适用于描述人物、地点或事物',
template: `[Objet de description] se trouve/est...\n\nPhysiquement, il/elle...\nDu point de vue du caractère...\n\nEn conclusion, [objet] est vraiment...`
}
]
const selectedType = ref('essay')
const writingTopic = ref('')
const userText = ref('')
const writingFeedback = ref<any>(null)
const loading = ref(false)
const error = ref('')
const getSectionTitle = (key: string) => {
const titles: Record<string, string> = {
grammar: '语法建议',
vocabulary: '词汇改进',
structure: '结构优化',
style: '文体建议',
overall: '总体评价'
}
return titles[key] || key
}
const getWritingHelp = async () => {
if (!userText.value.trim()) return
loading.value = true
error.value = ''
try {
// AI
await new Promise(resolve => setTimeout(resolve, 2000))
// AI
writingFeedback.value = {
overall: `您的作文整体结构清晰,主题明确。建议在以下方面进行改进:`,
grammar: [
'注意动词变位的准确性',
'检查形容词与名词的性数一致',
'确保时态使用的连贯性'
],
vocabulary: [
'可以使用更多高级词汇来丰富表达',
'避免重复使用相同的词汇',
'尝试使用同义词增加文章的多样性'
],
structure: [
'段落之间的过渡可以更加自然',
'可以增加更多的连接词',
'结论部分可以更加有力'
],
style: [
'保持正式的写作风格',
'句子长度可以适当变化',
'增加一些修辞手法会更好'
]
}
} catch (err: any) {
console.error('Writing help error:', err)
error.value = '获取写作指导失败,请稍后重试'
} finally {
loading.value = false
}
}
const useTemplate = (template: string) => {
if (userText.value && !confirm('使用模板将替换当前内容,确定继续吗?')) {
return
}
userText.value = template
}
</script>

5
start-backend.bat Normal file
View File

@ -0,0 +1,5 @@
@echo off
echo Starting backend server...
cd backend/dict-server
python main.py
pause

4
start-frontend.bat Normal file
View File

@ -0,0 +1,4 @@
@echo off
echo Starting frontend development server...
npm run dev
pause

20
tailwind.config.js Normal file
View File

@ -0,0 +1,20 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: '#2563eb', // blue-700
muted: '#6b7280', // gray-500
},
fontFamily: {
'deserta': ['Deserta', 'serif'],
'inter': ['Inter', 'sans-serif'],
},
},
},
plugins: [],
}