fix bug & update deployment
This commit is contained in:
parent
94cd289b66
commit
4302b9fd31
16
.env.example
16
.env.example
@ -1,14 +1,16 @@
|
|||||||
# 应用配置
|
# 应用配置
|
||||||
APP_NAME=Vue3 Python Notepad
|
APP_NAME=Notepad
|
||||||
|
|
||||||
# 安全配置
|
# 安全配置
|
||||||
# 请修改这个密码,建议使用强密码
|
FILE_LIST_PASSWORD=your_secure_password_change_this
|
||||||
FILE_LIST_PASSWORD=your_secure_password_here
|
JWT_SECRET_KEY=your_jwt_secret_key_change_this
|
||||||
|
|
||||||
# 端口配置
|
# 容器对外暴露的端口
|
||||||
# 容器对外暴露的端口(修改后需要更新 docker-compose.yml 中的端口映射)
|
PORT=80
|
||||||
EXTERNAL_PORT=80
|
|
||||||
|
|
||||||
# 文件存储配置
|
# 文件存储配置
|
||||||
# 容器外映射的文件存储目录(相对路径或绝对路径)
|
# 容器外映射的文件存储目录(相对路径或绝对路径)
|
||||||
FILES_DIR=./files
|
NOTES_DIR=./data
|
||||||
|
|
||||||
|
# 允许的跨域来源(正则表达式),多个来源用 | 分隔
|
||||||
|
# ALLOW_ORIGIN_REGEX=
|
||||||
103
DEPLOYMENT.md
103
DEPLOYMENT.md
@ -1,103 +0,0 @@
|
|||||||
# 生产环境部署指南
|
|
||||||
|
|
||||||
## 安全配置步骤
|
|
||||||
|
|
||||||
### 1. 配置环境变量
|
|
||||||
|
|
||||||
复制环境变量模板并修改敏感信息:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 复制模板文件
|
|
||||||
cp .env.example .env
|
|
||||||
|
|
||||||
# 编辑 .env 文件,修改以下敏感配置:
|
|
||||||
nano .env
|
|
||||||
```
|
|
||||||
|
|
||||||
**必须修改的配置:**
|
|
||||||
- `FILE_LIST_PASSWORD` - 设置强密码(建议使用随机生成的密码)
|
|
||||||
|
|
||||||
**生成强密码的方法:**
|
|
||||||
```bash
|
|
||||||
# 生成密码
|
|
||||||
openssl rand -base64 32
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 文件权限设置
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 创建文件目录(根据 .env 中的 FILES_DIR 配置)
|
|
||||||
mkdir -p ${FILES_DIR:-./files}
|
|
||||||
|
|
||||||
# 设置适当的权限
|
|
||||||
chmod 755 files
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 部署到服务器
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 克隆仓库
|
|
||||||
git clone https://github.com/YOUR_USERNAME/REPO_NAME.git
|
|
||||||
cd REPO_NAME
|
|
||||||
|
|
||||||
# 配置环境变量
|
|
||||||
cp .env.example .env
|
|
||||||
# 编辑 .env 文件...
|
|
||||||
|
|
||||||
# 构建并启动
|
|
||||||
docker-compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 验证部署
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 检查服务状态
|
|
||||||
docker-compose ps
|
|
||||||
|
|
||||||
# 查看日志
|
|
||||||
docker-compose logs -f
|
|
||||||
|
|
||||||
# 测试 API
|
|
||||||
curl http://localhost/api/health
|
|
||||||
```
|
|
||||||
|
|
||||||
## 环境变量说明
|
|
||||||
|
|
||||||
| 变量名 | 说明 | 默认值 | 是否必须修改 |
|
|
||||||
|--------|------|--------|------------|
|
|
||||||
| `APP_NAME` | 应用名称 | Vue3 Python Notepad | 否 |
|
|
||||||
| `FILE_LIST_PASSWORD` | 文件列表访问密码 | your_secure_password_here | **是** |
|
|
||||||
| `EXTERNAL_PORT` | 容器对外暴露的端口 | 80 | 否 |
|
|
||||||
| `FILES_DIR` | 文件存储目录(宿主机路径) | ./files | 否 |
|
|
||||||
|
|
||||||
## 安全建议
|
|
||||||
|
|
||||||
1. **定期更换密码**
|
|
||||||
- 建议每 3 个月更换一次 `FILE_LIST_PASSWORD`
|
|
||||||
|
|
||||||
2. **使用 HTTPS**
|
|
||||||
- 配置反向代理(Nginx/Traefik)
|
|
||||||
- 申请 SSL 证书(Let's Encrypt)
|
|
||||||
|
|
||||||
4. **备份**
|
|
||||||
- 定期备份 `FILES_DIR` 配置的目录
|
|
||||||
- 备份 `.env` 文件(存储在安全位置)
|
|
||||||
|
|
||||||
4. **监控**
|
|
||||||
- 监控容器资源使用
|
|
||||||
- 设置日志轮转
|
|
||||||
- 配置告警机制
|
|
||||||
|
|
||||||
## 故障排除
|
|
||||||
|
|
||||||
1. **密码错误**
|
|
||||||
- 检查 `.env` 文件中的 `FILE_LIST_PASSWORD`
|
|
||||||
- 重启容器:`docker-compose restart backend`
|
|
||||||
|
|
||||||
2. **认证错误**
|
|
||||||
- 检查 `.env` 文件中的 `FILE_LIST_PASSWORD`
|
|
||||||
- 清除浏览器 localStorage 中的 token
|
|
||||||
|
|
||||||
3. **权限问题**
|
|
||||||
- 确保 `files` 目录有写权限
|
|
||||||
- 检查 Docker 容器用户权限
|
|
||||||
113
DOCKER_DEPLOY.md
113
DOCKER_DEPLOY.md
@ -1,113 +0,0 @@
|
|||||||
# Vue3 + Python Notepad Docker 部署指南
|
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
### 前提条件
|
|
||||||
- Docker
|
|
||||||
- Docker Compose
|
|
||||||
|
|
||||||
### 部署步骤
|
|
||||||
|
|
||||||
1. **克隆项目**
|
|
||||||
```bash
|
|
||||||
git clone <repository-url>
|
|
||||||
cd bmadtest
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **构建并启动服务**
|
|
||||||
```bash
|
|
||||||
docker-compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **访问应用**
|
|
||||||
- 前端:http://localhost
|
|
||||||
- API:http://localhost/api
|
|
||||||
- WebSocket:ws://localhost/ws
|
|
||||||
|
|
||||||
### 常用命令
|
|
||||||
|
|
||||||
**查看服务状态**
|
|
||||||
```bash
|
|
||||||
docker-compose ps
|
|
||||||
```
|
|
||||||
|
|
||||||
**查看日志**
|
|
||||||
```bash
|
|
||||||
# 查看所有服务日志
|
|
||||||
docker-compose logs
|
|
||||||
|
|
||||||
# 查看特定服务日志
|
|
||||||
docker-compose logs backend
|
|
||||||
docker-compose logs frontend
|
|
||||||
```
|
|
||||||
|
|
||||||
**停止服务**
|
|
||||||
```bash
|
|
||||||
docker-compose down
|
|
||||||
```
|
|
||||||
|
|
||||||
**重新构建并启动**
|
|
||||||
```bash
|
|
||||||
docker-compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
### 数据持久化
|
|
||||||
|
|
||||||
文件数据存储在 `./files/notes/` 目录下。确保在宿主机上备份此目录以防止数据丢失。
|
|
||||||
|
|
||||||
### 环境变量配置
|
|
||||||
|
|
||||||
如需自定义配置,可以在 `docker-compose.yml` 中修改环境变量:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
environment:
|
|
||||||
- PYTHONPATH=/app
|
|
||||||
- PYTHONUNBUFFERED=1
|
|
||||||
# 其他环境变量...
|
|
||||||
```
|
|
||||||
|
|
||||||
### 端口配置
|
|
||||||
|
|
||||||
默认端口配置:
|
|
||||||
- 前端:80
|
|
||||||
- 后端:8000(容器内部)
|
|
||||||
|
|
||||||
如需修改前端端口,编辑 `docker-compose.yml` 中的 ports 配置:
|
|
||||||
```yaml
|
|
||||||
ports:
|
|
||||||
- "8080:80" # 将宿主机的 8080 端口映射到容器的 80 端口
|
|
||||||
```
|
|
||||||
|
|
||||||
### 故障排除
|
|
||||||
|
|
||||||
1. **端口被占用**
|
|
||||||
- 修改 docker-compose.yml 中的端口映射
|
|
||||||
- 或停止占用端口的服务
|
|
||||||
|
|
||||||
2. **权限问题**
|
|
||||||
- 确保 `./files` 目录有适当的写权限
|
|
||||||
- 运行:`chmod -R 755 ./files`
|
|
||||||
|
|
||||||
3. **构建失败**
|
|
||||||
- 清理 Docker 缓存:`docker system prune -a`
|
|
||||||
- 重新构建:`docker-compose build --no-cache`
|
|
||||||
|
|
||||||
### 生产环境建议
|
|
||||||
|
|
||||||
1. **使用 HTTPS**
|
|
||||||
- 配置 SSL 证书
|
|
||||||
- 使用反向代理(如 Traefik 或 Nginx)
|
|
||||||
|
|
||||||
2. **安全配置**
|
|
||||||
- 修改默认密码
|
|
||||||
- 限制 API 访问频率
|
|
||||||
- 使用防火墙
|
|
||||||
|
|
||||||
3. **监控**
|
|
||||||
- 配置日志收集
|
|
||||||
- 设置健康检查
|
|
||||||
- 监控资源使用情况
|
|
||||||
|
|
||||||
4. **备份**
|
|
||||||
- 定期备份 `./files` 目录
|
|
||||||
- 考虑使用云存储
|
|
||||||
40
Dockerfile
Normal file
40
Dockerfile
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
FROM node:18-alpine AS frontend-build
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY frontend/package*.json ./
|
||||||
|
COPY frontend/pnpm-lock.yaml ./
|
||||||
|
RUN npm install -g pnpm
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
COPY frontend/ ./
|
||||||
|
ARG APP_NAME=Notepad
|
||||||
|
RUN VITE_API_BASE_URL=/api VITE_WS_HOST= VITE_APP_TITLE=$APP_NAME pnpm build
|
||||||
|
|
||||||
|
FROM python:3.12-slim AS backend-prep
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
nginx \
|
||||||
|
supervisor \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY backend/requirements.txt ./
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
nginx \
|
||||||
|
supervisor \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=backend-prep /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
|
||||||
|
COPY --from=backend-prep /usr/local/bin /usr/local/bin
|
||||||
|
COPY backend/ ./
|
||||||
|
COPY --from=frontend-build /app/frontend/dist ./frontend/dist
|
||||||
|
RUN mkdir -p /app/data/notes /var/log/supervisor /var/log/nginx
|
||||||
|
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
COPY docker/default.conf /etc/nginx/sites-available/default
|
||||||
|
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
|
||||||
|
RUN chmod +x /app/docker-entrypoint.sh
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:${PORT:-80}/notepad/health || exit 1
|
||||||
|
EXPOSE ${PORT:-80}
|
||||||
|
CMD ["/app/docker-entrypoint.sh"]
|
||||||
208
README.md
208
README.md
@ -9,203 +9,153 @@
|
|||||||
- ✅ 文件列表管理(需口令验证)
|
- ✅ 文件列表管理(需口令验证)
|
||||||
- ✅ WebSocket实时通信
|
- ✅ WebSocket实时通信
|
||||||
- ✅ 响应式设计
|
- ✅ 响应式设计
|
||||||
- ✅ 文件大小限制(10万字符)
|
- ✅ 文件大小限制(100KB)
|
||||||
- ✅ 文件名验证与安全过滤
|
- ✅ 文件名验证与安全过滤
|
||||||
|
- ✅ 统一容器部署
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- **前端**: Vue 3, TypeScript, Vite, Pinia, Vue Router
|
- **前端**: Vue 3, TypeScript, Vite, Pinia, Vue Router
|
||||||
- **后端**: Python, FastAPI, WebSocket
|
- **后端**: Python 3.12, FastAPI, WebSocket
|
||||||
|
- **部署**: Docker, Nginx, Supervisor
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 前置要求
|
### Docker 部署(推荐)
|
||||||
|
|
||||||
- Node.js 18+
|
|
||||||
- Python 3.10+
|
|
||||||
- pnpm 8+
|
|
||||||
|
|
||||||
### 安装步骤
|
|
||||||
|
|
||||||
1. **克隆项目**
|
1. **克隆项目**
|
||||||
```bash
|
```bash
|
||||||
git clone <repository-url>
|
git clone <repository-url>
|
||||||
cd vue-python-notepad
|
cd notepad
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **安装依赖**
|
2. **配置环境变量**
|
||||||
```bash
|
```bash
|
||||||
# 安装前端依赖
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **启动服务**
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **访问应用**
|
||||||
|
- 应用地址: http://localhost:7024
|
||||||
|
- 默认密码: `your_secure_password_change_this`
|
||||||
|
|
||||||
|
### 开发环境
|
||||||
|
|
||||||
|
1. **安装依赖**
|
||||||
|
```bash
|
||||||
|
# 前端
|
||||||
cd frontend && pnpm install && cd ..
|
cd frontend && pnpm install && cd ..
|
||||||
|
|
||||||
# 设置Python虚拟环境并安装后端依赖
|
# 后端
|
||||||
cd backend
|
cd backend
|
||||||
python -m venv venv
|
python -m venv venv
|
||||||
# Windows
|
source venv/bin/activate # Linux/Mac
|
||||||
venv\Scripts\activate
|
# 或 venv\Scripts\activate # Windows
|
||||||
# macOS/Linux
|
|
||||||
source venv/bin/activate
|
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
cd ..
|
cd ..
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **配置环境变量**
|
2. **启动服务**
|
||||||
```bash
|
```bash
|
||||||
# 复制环境变量模板
|
# 后端
|
||||||
cp backend/.env.example .env
|
cd backend && source venv/bin/activate && python main.py
|
||||||
|
|
||||||
# 编辑.env文件,设置必要的环境变量
|
# 前端(新终端)
|
||||||
|
cd frontend && pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **启动开发服务器**
|
|
||||||
|
|
||||||
**方法一:分别启动**
|
|
||||||
```bash
|
|
||||||
# 启动后端服务器(终端1)
|
|
||||||
cd backend
|
|
||||||
venv\Scripts\activate # Windows
|
|
||||||
python main.py
|
|
||||||
|
|
||||||
# 启动前端开发服务器(终端2)
|
|
||||||
cd frontend
|
|
||||||
pnpm dev
|
|
||||||
```
|
|
||||||
|
|
||||||
**方法二:同时启动**
|
|
||||||
```bash
|
|
||||||
# 在项目根目录
|
|
||||||
pnpm dev
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **访问应用**
|
|
||||||
- 前端: http://localhost:5173
|
|
||||||
- 后端API: http://localhost:8000
|
|
||||||
- API文档: http://localhost:8000/docs
|
|
||||||
|
|
||||||
## 使用说明
|
## 使用说明
|
||||||
|
|
||||||
### 基本操作
|
### 基本操作
|
||||||
|
|
||||||
1. **创建/编辑文件**
|
1. **创建/编辑文件**
|
||||||
- 访问 `http://localhost:5173/notes/filename.txt`
|
- 访问 `http://localhost:7024/notes/filename.txt`
|
||||||
- 如果文件不存在,会自动创建
|
- 文件不存在时自动创建
|
||||||
- 输入内容会自动保存(500ms延迟)
|
- 内容自动保存
|
||||||
|
|
||||||
2. **文件列表**
|
2. **文件列表管理**
|
||||||
- 访问 `http://localhost:5173/file-list`
|
- 访问 `http://localhost:7024/file-list`
|
||||||
- 输入口令(默认:your_secure_password)
|
- 输入口令验证
|
||||||
- 查看和管理所有文件
|
- 查看、重命名、删除文件
|
||||||
|
|
||||||
3. **URL分享**
|
3. **URL分享**
|
||||||
- 直接分享文件URL给他人
|
- 直接分享文件URL
|
||||||
- 例如:`http://localhost:5173/notes/shared-note.txt`
|
- 例如:`http://localhost:7024/notes/shared-note.txt`
|
||||||
|
|
||||||
### 环境变量配置
|
### 环境变量配置
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 前端环境变量
|
# 应用配置
|
||||||
VITE_API_BASE_URL=http://localhost:8000/api
|
PORT=7024 # 容器对外端口
|
||||||
VITE_WS_BASE_URL=ws://localhost:8000/ws
|
FILE_LIST_PASSWORD=your_password # 文件列表访问密码
|
||||||
|
|
||||||
# 后端环境变量
|
# 文件存储
|
||||||
NOTES_DIR=./data/notes # 文件存储目录
|
NOTES_DIR=./data # 容器外映射存储路径
|
||||||
FILE_LIST_PASSWORD=your_secure_password # 文件列表访问口令
|
|
||||||
JWT_SECRET_KEY=your_jwt_secret_key # JWT密钥
|
|
||||||
JWT_EXPIRE_HOURS=24 # 令牌过期时间
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
vue-python-notepad/
|
notepad/
|
||||||
├── frontend/ # Vue3前端应用
|
├── frontend/ # Vue3前端
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── components/ # UI组件
|
|
||||||
│ │ ├── views/ # 页面组件
|
│ │ ├── views/ # 页面组件
|
||||||
│ │ ├── stores/ # 状态管理
|
|
||||||
│ │ ├── services/ # API服务
|
│ │ ├── services/ # API服务
|
||||||
│ │ └── router/ # 路由配置
|
│ │ └── stores/ # 状态管理
|
||||||
│ ├── package.json
|
│ └── package.json
|
||||||
│ └── vite.config.ts
|
├── backend/ # FastAPI后端
|
||||||
├── backend/ # FastAPI后端应用
|
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── api/ # API路由
|
│ │ ├── api/ # API路由
|
||||||
│ │ ├── services/ # 业务逻辑
|
│ │ ├── services/ # 业务逻辑
|
||||||
│ │ ├── models/ # 数据模型
|
|
||||||
│ │ └── utils/ # 工具函数
|
│ │ └── utils/ # 工具函数
|
||||||
│ ├── main.py
|
|
||||||
│ └── requirements.txt
|
│ └── requirements.txt
|
||||||
├── docs/ # 项目文档
|
├── docker/ # Docker配置
|
||||||
├── package.json # 根配置文件
|
│ ├── nginx.conf
|
||||||
|
│ ├── default.conf
|
||||||
|
│ └── supervisord.conf
|
||||||
|
├── docker-compose.yml # 容器编排
|
||||||
|
├── Dockerfile # 统一构建文件
|
||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## 开发指南
|
## 部署架构
|
||||||
|
|
||||||
### 添加新功能
|
### 容器架构
|
||||||
|
- **Nginx**: 静态文件服务 + API代理 + WebSocket代理
|
||||||
|
- **Python FastAPI**: API服务 + WebSocket服务
|
||||||
|
- **Supervisor**: 进程管理
|
||||||
|
|
||||||
1. **前端组件**
|
### 网络流程
|
||||||
- 在 `frontend/src/components/` 中创建组件
|
|
||||||
- 在 `frontend/src/views/` 中创建页面
|
|
||||||
- 使用 Pinia 管理状态
|
|
||||||
|
|
||||||
2. **后端API**
|
|
||||||
- 在 `backend/src/api/` 中添加路由
|
|
||||||
- 在 `backend/src/services/` 中实现业务逻辑
|
|
||||||
- 在 `backend/src/models/` 中定义数据模型
|
|
||||||
|
|
||||||
### 测试
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 运行前端测试
|
|
||||||
pnpm test:frontend
|
|
||||||
|
|
||||||
# 运行后端测试
|
|
||||||
pnpm test:backend
|
|
||||||
|
|
||||||
# 运行所有测试
|
|
||||||
pnpm test
|
|
||||||
```
|
```
|
||||||
|
用户请求 → Nginx(80) → 后端(7025)
|
||||||
### 构建部署
|
↓
|
||||||
|
前端静态文件
|
||||||
```bash
|
|
||||||
# 构建前端
|
|
||||||
pnpm build
|
|
||||||
|
|
||||||
# 生产环境启动
|
|
||||||
cd backend
|
|
||||||
venv\Scripts\activate # Windows
|
|
||||||
python main.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 故障排除
|
## 故障排除
|
||||||
|
|
||||||
### 常见问题
|
### 常见问题
|
||||||
|
|
||||||
1. **端口冲突**
|
1. **容器启动失败**
|
||||||
- 确保8000和5173端口未被占用
|
```bash
|
||||||
- 或修改配置文件中的端口设置
|
docker compose down
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
2. **Python依赖问题**
|
2. **文件不持久化**
|
||||||
- 确保使用Python 3.10+
|
- 检查 `.env` 中的 `NOTES_DIR` 配置
|
||||||
- 激活虚拟环境后安装依赖
|
- 确认数据目录权限
|
||||||
|
|
||||||
3. **前端代理错误**
|
3. **WebSocket连接失败**
|
||||||
- 检查 `frontend/vite.config.ts` 中的代理配置
|
- 检查 Nginx 配置
|
||||||
- 确保后端服务器正在运行
|
- 确认防火墙设置
|
||||||
|
|
||||||
4. **WebSocket连接失败**
|
4. **端口冲突**
|
||||||
- 检查防火墙设置
|
- 修改 `.env` 中的 `PORT` 变量
|
||||||
- 确认WebSocket URL正确
|
|
||||||
|
|
||||||
## 贡献指南
|
|
||||||
|
|
||||||
1. Fork 项目
|
|
||||||
2. 创建功能分支
|
|
||||||
3. 提交更改
|
|
||||||
4. 推送到分支
|
|
||||||
5. 创建 Pull Request
|
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +0,0 @@
|
|||||||
# 文件存储配置
|
|
||||||
NOTES_DIR=./data/notes
|
|
||||||
|
|
||||||
# 认证配置 - 请在生产环境中更改这些值
|
|
||||||
FILE_LIST_PASSWORD=your_secure_password_change_this
|
|
||||||
JWT_SECRET_KEY=your_jwt_secret_key_change_this_in_production
|
|
||||||
JWT_ALGORITHM=HS256
|
|
||||||
JWT_EXPIRE_HOURS=24
|
|
||||||
|
|
||||||
# 服务器配置
|
|
||||||
HOST=0.0.0.0
|
|
||||||
PORT=8000
|
|
||||||
DEBUG=true
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
FROM python:3.11-slim
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# 安装系统依赖
|
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
gcc \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# 复制依赖文件
|
|
||||||
COPY requirements.txt .
|
|
||||||
|
|
||||||
# 安装 Python 依赖
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# 复制源代码
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# 创建数据目录
|
|
||||||
RUN mkdir -p data/notes
|
|
||||||
|
|
||||||
# 暴露端口
|
|
||||||
EXPOSE 8000
|
|
||||||
|
|
||||||
# 设置环境变量
|
|
||||||
ENV PYTHONPATH=/app
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
|
||||||
|
|
||||||
# 启动命令
|
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
|
||||||
@ -7,41 +7,34 @@ import os
|
|||||||
from src.api import notes, files, websocket
|
from src.api import notes, files, websocket
|
||||||
from src.utils.config import get_settings
|
from src.utils.config import get_settings
|
||||||
|
|
||||||
app = FastAPI(
|
settings = get_settings()
|
||||||
title="Vue3 + Python Notepad API",
|
app = FastAPI(title=f"{settings.app_name} API", description="NOTEPAD API", version="1.0.0")
|
||||||
description="简单的文本编辑器API,支持文件读写和列表功能",
|
|
||||||
version="1.0.0"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 配置CORS
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["http://localhost:5173", "ws://localhost:5173"], # Vue开发服务器
|
allow_origin_regex=(
|
||||||
|
settings.allow_origin_regex
|
||||||
|
if settings.allow_origin_regex
|
||||||
|
else r"http[s]?://localhost:5173|http[s]?://127\.0\.0\.1:5173|http[s]?://172\.30\.0\..*:5173|ws[s]?://localhost:5173|ws[s]?://127\.0\.0\.1:5173"
|
||||||
|
),
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["GET", "POST"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# 注册路由
|
app.include_router(notes.router, prefix="/notepad", tags=["notes"])
|
||||||
app.include_router(notes.router, prefix="/api", tags=["notes"])
|
app.include_router(files.router, prefix="/notepad", tags=["files"])
|
||||||
app.include_router(files.router, prefix="/api", tags=["files"])
|
app.include_router(websocket.router, prefix="/notepad/ws", tags=["websocket"])
|
||||||
app.include_router(websocket.router, prefix="/ws", tags=["websocket"])
|
|
||||||
|
|
||||||
|
@app.get("/notepad/health")
|
||||||
|
async def health_check():
|
||||||
|
return {"status": "healthy"}
|
||||||
|
|
||||||
|
|
||||||
# 静态文件服务(用于生产环境)
|
|
||||||
if os.path.exists("./static"):
|
if os.path.exists("./static"):
|
||||||
app.mount("/", StaticFiles(directory="./static", html=True), name="static")
|
app.mount("/", StaticFiles(directory="./static", html=True), name="static")
|
||||||
|
|
||||||
@app.get("/health")
|
|
||||||
async def health_check():
|
|
||||||
"""健康检查端点"""
|
|
||||||
return {"status": "healthy"}
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
settings = get_settings()
|
port = int(os.getenv("PORT", 7024))
|
||||||
uvicorn.run(
|
uvicorn.run("main:app", host="0.0.0.0", port=port, reload=True, log_level="info")
|
||||||
"main:app",
|
|
||||||
host="0.0.0.0",
|
|
||||||
port=8000,
|
|
||||||
reload=True,
|
|
||||||
log_level="info"
|
|
||||||
)
|
|
||||||
|
|||||||
@ -9,18 +9,17 @@ file_service = FileService()
|
|||||||
auth_service = AuthService()
|
auth_service = AuthService()
|
||||||
security = HTTPBearer()
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
async def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
async def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||||
"""验证JWT令牌"""
|
"""验证JWT令牌"""
|
||||||
token = credentials.credentials
|
token = credentials.credentials
|
||||||
|
|
||||||
if not auth_service.validate_token(token):
|
if not auth_service.validate_token(token):
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=403, detail="Invalid or expired token")
|
||||||
status_code=403,
|
|
||||||
detail="Invalid or expired token"
|
|
||||||
)
|
|
||||||
|
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
@router.post("/verify-password", response_model=AuthToken)
|
@router.post("/verify-password", response_model=AuthToken)
|
||||||
async def verify_password(request: PasswordRequest):
|
async def verify_password(request: PasswordRequest):
|
||||||
"""验证访问口令"""
|
"""验证访问口令"""
|
||||||
@ -32,32 +31,28 @@ async def verify_password(request: PasswordRequest):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/list", response_model=FileListResponse, dependencies=[Depends(verify_token)])
|
@router.get("/list", response_model=FileListResponse, dependencies=[Depends(verify_token)])
|
||||||
async def get_files_list(
|
async def get_files_list(
|
||||||
page: int = Query(1, ge=1, description="页码"),
|
page: int = Query(1, ge=1, description="页码"), limit: int = Query(50, ge=1, le=100, description="每页文件数")
|
||||||
limit: int = Query(50, ge=1, le=100, description="每页文件数")
|
|
||||||
):
|
):
|
||||||
"""获取文件列表(需要认证)"""
|
"""获取文件列表(需要认证)"""
|
||||||
try:
|
try:
|
||||||
files = file_service.list_files(page, limit)
|
files = file_service.list_files(page, limit)
|
||||||
total = file_service.get_total_files_count()
|
total = file_service.get_total_files_count()
|
||||||
|
|
||||||
return FileListResponse(
|
return FileListResponse(files=files, total=total, page=page, limit=limit)
|
||||||
files=files,
|
|
||||||
total=total,
|
|
||||||
page=page,
|
|
||||||
limit=limit
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@router.delete("/{filename}")
|
|
||||||
|
@router.post("/{filename}/delete")
|
||||||
async def delete_file(filename: str):
|
async def delete_file(filename: str):
|
||||||
"""删除文件"""
|
"""删除文件"""
|
||||||
try:
|
try:
|
||||||
if not file_service.file_exists(filename):
|
if not file_service.file_exists(filename):
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
file_service.delete_file(filename)
|
file_service.delete_file(filename)
|
||||||
return {"message": f"File {filename} deleted successfully"}
|
return {"message": f"File {filename} deleted successfully"}
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
@ -65,16 +60,20 @@ async def delete_file(filename: str):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@router.put("/{filename}/rename")
|
|
||||||
|
@router.post("/{filename}/rename")
|
||||||
async def rename_file(filename: str, new_filename: str = Query(..., description="新文件名")):
|
async def rename_file(filename: str, new_filename: str = Query(..., description="新文件名")):
|
||||||
"""重命名文件"""
|
"""重命名文件"""
|
||||||
try:
|
try:
|
||||||
|
if not file_service._validate_filename(new_filename):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid new filename")
|
||||||
|
|
||||||
if not file_service.file_exists(filename):
|
if not file_service.file_exists(filename):
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
return {"message": f"File renamed to {new_filename}"}
|
||||||
|
|
||||||
if file_service.file_exists(new_filename):
|
if file_service.file_exists(new_filename):
|
||||||
raise HTTPException(status_code=400, detail="File with new name already exists")
|
raise HTTPException(status_code=400, detail="File with new name already exists")
|
||||||
|
|
||||||
file_service.rename_file(filename, new_filename)
|
file_service.rename_file(filename, new_filename)
|
||||||
return {"message": f"File renamed from {filename} to {new_filename}"}
|
return {"message": f"File renamed from {filename} to {new_filename}"}
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
@ -82,4 +81,4 @@ async def rename_file(filename: str, new_filename: str = Query(..., description=
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|||||||
@ -7,18 +7,18 @@ from ..services.file_service import FileService
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
file_service = FileService()
|
file_service = FileService()
|
||||||
|
|
||||||
|
|
||||||
class ConnectionManager:
|
class ConnectionManager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.active_connections: Dict[str, WebSocket] = {}
|
self.active_connections: Dict[str, WebSocket] = {}
|
||||||
|
|
||||||
async def connect(self, websocket: WebSocket, filename: str):
|
async def connect(self, websocket: WebSocket, filename: str):
|
||||||
await websocket.accept()
|
|
||||||
self.active_connections[filename] = websocket
|
self.active_connections[filename] = websocket
|
||||||
|
|
||||||
def disconnect(self, filename: str):
|
def disconnect(self, filename: str):
|
||||||
if filename in self.active_connections:
|
if filename in self.active_connections:
|
||||||
del self.active_connections[filename]
|
del self.active_connections[filename]
|
||||||
|
|
||||||
async def send_message(self, message: dict, filename: str):
|
async def send_message(self, message: dict, filename: str):
|
||||||
if filename in self.active_connections:
|
if filename in self.active_connections:
|
||||||
websocket = self.active_connections[filename]
|
websocket = self.active_connections[filename]
|
||||||
@ -28,74 +28,73 @@ class ConnectionManager:
|
|||||||
# 连接已断开,清理
|
# 连接已断开,清理
|
||||||
self.disconnect(filename)
|
self.disconnect(filename)
|
||||||
|
|
||||||
|
|
||||||
manager = ConnectionManager()
|
manager = ConnectionManager()
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/{filename}")
|
@router.websocket("/{filename}")
|
||||||
async def websocket_endpoint(websocket: WebSocket, filename: str):
|
async def websocket_endpoint(websocket: WebSocket, filename: str):
|
||||||
print(f"WebSocket connection attempt for file: {filename}")
|
# print(f"WebSocket connection attempt for file: {filename}")
|
||||||
|
# print(f"WebSocket headers: {websocket.headers}")
|
||||||
try:
|
try:
|
||||||
|
await websocket.accept()
|
||||||
await manager.connect(websocket, filename)
|
await manager.connect(websocket, filename)
|
||||||
print(f"WebSocket connected for file: {filename}")
|
# print(f"WebSocket connected for file: {filename}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"WebSocket connection failed: {e}")
|
# print(f"WebSocket connection failed: {e}")
|
||||||
await websocket.close(code=1006)
|
# print(f"Exception type: {type(e)}")
|
||||||
|
try:
|
||||||
|
await websocket.close(code=1006)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
# 接收客户端消息
|
|
||||||
data = await websocket.receive_text()
|
data = await websocket.receive_text()
|
||||||
message = json.loads(data)
|
message = json.loads(data)
|
||||||
|
|
||||||
# 处理保存请求
|
|
||||||
if message.get("type") == "save":
|
if message.get("type") == "save":
|
||||||
try:
|
try:
|
||||||
content = message.get("content", "")
|
content = message.get("content", "")
|
||||||
|
|
||||||
# 检查内容是否为空
|
|
||||||
if not content or content.strip() == "":
|
if not content or content.strip() == "":
|
||||||
# 删除空文件
|
|
||||||
if file_service.file_exists(filename):
|
if file_service.file_exists(filename):
|
||||||
file_service.delete_file(filename)
|
file_service.delete_file(filename)
|
||||||
response = {
|
response = {
|
||||||
"type": "save_response",
|
"type": "save_response",
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": "空文件已删除",
|
"message": "空文件已删除",
|
||||||
"timestamp": message.get("timestamp")
|
"timestamp": message.get("timestamp"),
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
response = {
|
response = {
|
||||||
"type": "save_response",
|
"type": "save_response",
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": "文件不存在",
|
"message": "文件不存在",
|
||||||
"timestamp": message.get("timestamp")
|
"timestamp": message.get("timestamp"),
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
# 保存非空文件
|
|
||||||
file_service.write_file(filename, content)
|
file_service.write_file(filename, content)
|
||||||
response = {
|
response = {
|
||||||
"type": "save_response",
|
"type": "save_response",
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": "保存成功",
|
"message": "保存成功",
|
||||||
"timestamp": message.get("timestamp")
|
"timestamp": message.get("timestamp"),
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 发送失败响应
|
|
||||||
response = {
|
response = {
|
||||||
"type": "save_response",
|
"type": "save_response",
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"message": str(e),
|
"message": str(e),
|
||||||
"timestamp": message.get("timestamp")
|
"timestamp": message.get("timestamp"),
|
||||||
}
|
}
|
||||||
|
|
||||||
await manager.send_message(response, filename)
|
await manager.send_message(response, filename)
|
||||||
|
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
manager.disconnect(filename)
|
manager.disconnect(filename)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 发送错误响应
|
error_response = {"type": "error", "message": str(e)}
|
||||||
error_response = {
|
|
||||||
"type": "error",
|
|
||||||
"message": str(e)
|
|
||||||
}
|
|
||||||
await manager.send_message(error_response, filename)
|
await manager.send_message(error_response, filename)
|
||||||
manager.disconnect(filename)
|
manager.disconnect(filename)
|
||||||
|
|||||||
@ -4,29 +4,24 @@ import jwt
|
|||||||
from ..models.text_file import AuthToken
|
from ..models.text_file import AuthToken
|
||||||
from ..utils.config import get_settings
|
from ..utils.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
class AuthService:
|
class AuthService:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.settings = get_settings()
|
self.settings = get_settings()
|
||||||
|
|
||||||
def verify_password(self, password: str) -> AuthToken:
|
def verify_password(self, password: str) -> AuthToken:
|
||||||
"""验证口令并生成令牌"""
|
|
||||||
if password != self.settings.file_list_password:
|
if password != self.settings.file_list_password:
|
||||||
raise ValueError("Invalid password")
|
raise ValueError("Invalid password")
|
||||||
|
|
||||||
# 生成JWT令牌
|
expires_delta = timedelta(minutes=self.settings.jwt_expire_minutes)
|
||||||
expires_delta = timedelta(hours=self.settings.jwt_expire_hours)
|
|
||||||
expires_at = datetime.utcnow() + expires_delta
|
expires_at = datetime.utcnow() + expires_delta
|
||||||
|
|
||||||
to_encode = {
|
payload = {
|
||||||
"exp": expires_at,
|
"exp": expires_at,
|
||||||
"sub": "file_list_access"
|
"sub": "file_list_access"
|
||||||
}
|
}
|
||||||
|
|
||||||
token = jwt.encode(
|
token = jwt.encode(payload, self.settings.jwt_secret_key, algorithm="HS256")
|
||||||
to_encode,
|
|
||||||
self.settings.jwt_secret_key,
|
|
||||||
algorithm=self.settings.jwt_algorithm
|
|
||||||
)
|
|
||||||
|
|
||||||
return AuthToken(
|
return AuthToken(
|
||||||
token=token,
|
token=token,
|
||||||
@ -34,13 +29,8 @@ class AuthService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def validate_token(self, token: str) -> bool:
|
def validate_token(self, token: str) -> bool:
|
||||||
"""验证JWT令牌"""
|
|
||||||
try:
|
try:
|
||||||
jwt.decode(
|
jwt.decode(token, self.settings.jwt_secret_key, algorithms=["HS256"])
|
||||||
token,
|
|
||||||
self.settings.jwt_secret_key,
|
|
||||||
algorithms=[self.settings.jwt_algorithm]
|
|
||||||
)
|
|
||||||
return True
|
return True
|
||||||
except jwt.PyJWTError:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@ -6,113 +6,113 @@ from pathlib import Path
|
|||||||
from ..models.text_file import TextFile, FileListItem
|
from ..models.text_file import TextFile, FileListItem
|
||||||
from ..utils.config import get_settings
|
from ..utils.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
class FileService:
|
class FileService:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.settings = get_settings()
|
self.settings = get_settings()
|
||||||
self.notes_dir = Path(self.settings.notes_dir)
|
self.notes_dir = Path(self.settings.notes_path)
|
||||||
self.notes_dir.mkdir(parents=True, exist_ok=True)
|
self.notes_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
def _validate_filename(self, filename: str) -> bool:
|
def _validate_filename(self, filename: str) -> bool:
|
||||||
"""验证文件名是否安全"""
|
|
||||||
# 检查文件名长度
|
|
||||||
if len(filename) > 200 or len(filename) < 1:
|
if len(filename) > 200 or len(filename) < 1:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 检查文件名是否包含非法字符
|
illegal_chars = r'[^a-zA-Z0-9_.-]'
|
||||||
illegal_chars = r'[<>:"/\\|?*\x00-\x1f]'
|
if (
|
||||||
if re.search(illegal_chars, filename):
|
re.search(illegal_chars, filename)
|
||||||
|
or filename.startswith("_")
|
||||||
|
or filename.startswith(".")
|
||||||
|
or filename.startswith("-")
|
||||||
|
):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 检查路径遍历攻击
|
|
||||||
if '..' in filename or filename.startswith('/'):
|
if '..' in filename or filename.startswith('/'):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 确保文件扩展名为.txt
|
|
||||||
if not filename.lower().endswith('.txt'):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _add_txt_extension(self, filename: str) -> str:
|
||||||
|
if not filename.lower().endswith('.txt'):
|
||||||
|
return f"{filename}.txt"
|
||||||
|
return filename
|
||||||
|
|
||||||
def read_file(self, filename: str) -> TextFile:
|
def read_file(self, filename: str) -> TextFile:
|
||||||
"""读取文件内容"""
|
|
||||||
if not self._validate_filename(filename):
|
if not self._validate_filename(filename):
|
||||||
raise ValueError(f"Invalid filename: {filename}")
|
raise ValueError(f"Invalid filename: {filename}")
|
||||||
|
|
||||||
|
filename = self._add_txt_extension(filename)
|
||||||
file_path = self.notes_dir / filename
|
file_path = self.notes_dir / filename
|
||||||
|
|
||||||
if not file_path.exists():
|
if not file_path.exists():
|
||||||
raise FileNotFoundError(f"File {filename} not found")
|
raise FileNotFoundError(f"File {filename} not found")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(file_path, 'r', encoding='utf-8') as f:
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
|
|
||||||
stat = file_path.stat()
|
stat = file_path.stat()
|
||||||
return TextFile(
|
return TextFile(
|
||||||
filename=filename,
|
filename=filename,
|
||||||
content=content,
|
content=content,
|
||||||
created_at=datetime.fromtimestamp(stat.st_ctime),
|
created_at=datetime.fromtimestamp(stat.st_ctime),
|
||||||
updated_at=datetime.fromtimestamp(stat.st_mtime),
|
updated_at=datetime.fromtimestamp(stat.st_mtime),
|
||||||
size=len(content)
|
size=len(content),
|
||||||
)
|
)
|
||||||
except UnicodeDecodeError:
|
except UnicodeDecodeError:
|
||||||
raise ValueError(f"File {filename} is not a valid text file")
|
raise ValueError(f"File {filename} is not a valid text file")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise RuntimeError(f"Error reading file {filename}: {str(e)}")
|
raise RuntimeError(f"Error reading file {filename}: {str(e)}")
|
||||||
|
|
||||||
def write_file(self, filename: str, content: str) -> TextFile:
|
def write_file(self, filename: str, content: str) -> TextFile:
|
||||||
"""写入文件内容"""
|
|
||||||
if not self._validate_filename(filename):
|
if not self._validate_filename(filename):
|
||||||
raise ValueError(f"Invalid filename: {filename}")
|
raise ValueError(f"Invalid filename: {filename}")
|
||||||
|
|
||||||
if content is None:
|
if content is None:
|
||||||
raise ValueError("Content cannot be None")
|
raise ValueError("Content cannot be None")
|
||||||
|
|
||||||
if len(content) > 100000:
|
if len(content) > 100000:
|
||||||
raise ValueError("File content too large (max 100,000 characters)")
|
raise ValueError("File content too large (max 100,000 characters)")
|
||||||
|
|
||||||
|
filename = self._add_txt_extension(filename)
|
||||||
file_path = self.notes_dir / filename
|
file_path = self.notes_dir / filename
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(file_path, 'w', encoding='utf-8') as f:
|
with open(file_path, 'w', encoding='utf-8') as f:
|
||||||
f.write(content)
|
f.write(content)
|
||||||
|
|
||||||
stat = file_path.stat()
|
stat = file_path.stat()
|
||||||
return TextFile(
|
return TextFile(
|
||||||
filename=filename,
|
filename=filename,
|
||||||
content=content,
|
content=content,
|
||||||
created_at=datetime.fromtimestamp(stat.st_ctime),
|
created_at=datetime.fromtimestamp(stat.st_ctime),
|
||||||
updated_at=datetime.fromtimestamp(stat.st_mtime),
|
updated_at=datetime.fromtimestamp(stat.st_mtime),
|
||||||
size=len(content)
|
size=len(content),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise RuntimeError(f"Error writing file {filename}: {str(e)}")
|
raise RuntimeError(f"Error writing file {filename}: {str(e)}")
|
||||||
|
|
||||||
def create_file(self, filename: str) -> TextFile:
|
def create_file(self, filename: str) -> TextFile:
|
||||||
"""创建新文件"""
|
"""创建新文件"""
|
||||||
return self.write_file(filename, "")
|
return self.write_file(filename, "")
|
||||||
|
|
||||||
def list_files(self, page: int = 1, limit: int = 50) -> List[FileListItem]:
|
def list_files(self, page: int = 1, limit: int = 50) -> List[FileListItem]:
|
||||||
"""列出文件"""
|
|
||||||
if page < 1:
|
if page < 1:
|
||||||
page = 1
|
page = 1
|
||||||
|
|
||||||
if limit < 1 or limit > 100:
|
if limit < 1 or limit > 100:
|
||||||
limit = 50
|
limit = 50
|
||||||
|
|
||||||
files = []
|
files = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for file_path in self.notes_dir.glob("*.txt"):
|
for file_path in self.notes_dir.glob("*.txt"):
|
||||||
stat = file_path.stat()
|
stat = file_path.stat()
|
||||||
|
|
||||||
# 读取文件前100个字符作为预览
|
|
||||||
preview = ""
|
preview = ""
|
||||||
if stat.st_size > 0:
|
if stat.st_size > 0:
|
||||||
try:
|
try:
|
||||||
with open(file_path, 'r', encoding='utf-8') as f:
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
content = f.read(100)
|
content = f.read(100)
|
||||||
# 清理预览内容,移除换行符
|
|
||||||
preview = content.replace('\n', ' ').replace('\r', '').strip()
|
preview = content.replace('\n', ' ').replace('\r', '').strip()
|
||||||
if len(preview) > 50:
|
if len(preview) > 50:
|
||||||
preview = preview[:50] + "..."
|
preview = preview[:50] + "..."
|
||||||
@ -120,75 +120,76 @@ class FileService:
|
|||||||
preview = "[非文本文件]"
|
preview = "[非文本文件]"
|
||||||
except Exception:
|
except Exception:
|
||||||
preview = ""
|
preview = ""
|
||||||
|
|
||||||
files.append(FileListItem(
|
files.append(
|
||||||
filename=file_path.name,
|
FileListItem(
|
||||||
created_at=datetime.fromtimestamp(stat.st_ctime),
|
filename=file_path.name,
|
||||||
size=stat.st_size,
|
created_at=datetime.fromtimestamp(stat.st_ctime),
|
||||||
preview=preview
|
size=stat.st_size,
|
||||||
))
|
preview=preview,
|
||||||
|
)
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise RuntimeError(f"Error listing files: {str(e)}")
|
raise RuntimeError(f"Error listing files: {str(e)}")
|
||||||
|
|
||||||
# 按创建时间倒序排列
|
|
||||||
files.sort(key=lambda x: x.created_at, reverse=True)
|
files.sort(key=lambda x: x.created_at, reverse=True)
|
||||||
|
|
||||||
# 分页
|
|
||||||
start = (page - 1) * limit
|
start = (page - 1) * limit
|
||||||
end = start + limit
|
end = start + limit
|
||||||
|
|
||||||
return files[start:end]
|
return files[start:end]
|
||||||
|
|
||||||
def get_total_files_count(self) -> int:
|
def get_total_files_count(self) -> int:
|
||||||
"""获取文件总数"""
|
|
||||||
try:
|
try:
|
||||||
return len(list(self.notes_dir.glob("*.txt")))
|
return len(list(self.notes_dir.glob("*.txt")))
|
||||||
except Exception:
|
except Exception:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def file_exists(self, filename: str) -> bool:
|
def file_exists(self, filename: str) -> bool:
|
||||||
"""检查文件是否存在"""
|
|
||||||
if not self._validate_filename(filename):
|
if not self._validate_filename(filename):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
filename = self._add_txt_extension(filename)
|
||||||
file_path = self.notes_dir / filename
|
file_path = self.notes_dir / filename
|
||||||
return file_path.exists() and file_path.is_file()
|
return file_path.exists() and file_path.is_file()
|
||||||
|
|
||||||
def delete_file(self, filename: str) -> bool:
|
def delete_file(self, filename: str) -> bool:
|
||||||
"""删除文件"""
|
|
||||||
if not self._validate_filename(filename):
|
if not self._validate_filename(filename):
|
||||||
raise ValueError(f"Invalid filename: {filename}")
|
raise ValueError(f"Invalid filename: {filename}")
|
||||||
|
|
||||||
|
filename = self._add_txt_extension(filename)
|
||||||
file_path = self.notes_dir / filename
|
file_path = self.notes_dir / filename
|
||||||
|
|
||||||
if not file_path.exists():
|
if not file_path.exists():
|
||||||
raise FileNotFoundError(f"File {filename} not found")
|
return True # 文件已不存在
|
||||||
|
|
||||||
try:
|
try:
|
||||||
file_path.unlink()
|
file_path.unlink()
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise RuntimeError(f"Error deleting file {filename}: {str(e)}")
|
raise RuntimeError(f"Error deleting file {filename}: {str(e)}")
|
||||||
|
|
||||||
def rename_file(self, old_filename: str, new_filename: str) -> bool:
|
def rename_file(self, old_filename: str, new_filename: str) -> bool:
|
||||||
"""重命名文件"""
|
|
||||||
if not self._validate_filename(old_filename):
|
if not self._validate_filename(old_filename):
|
||||||
raise ValueError(f"Invalid old filename: {old_filename}")
|
raise ValueError(f"Invalid old filename: {old_filename}")
|
||||||
|
|
||||||
if not self._validate_filename(new_filename):
|
if not self._validate_filename(new_filename):
|
||||||
raise ValueError(f"Invalid new filename: {new_filename}")
|
raise ValueError(f"Invalid new filename: {new_filename}")
|
||||||
|
|
||||||
|
old_filename = self._add_txt_extension(old_filename)
|
||||||
|
new_filename = self._add_txt_extension(new_filename)
|
||||||
|
|
||||||
old_path = self.notes_dir / old_filename
|
old_path = self.notes_dir / old_filename
|
||||||
new_path = self.notes_dir / new_filename
|
new_path = self.notes_dir / new_filename
|
||||||
|
|
||||||
if not old_path.exists():
|
if not old_path.exists():
|
||||||
raise FileNotFoundError(f"File {old_filename} not found")
|
return True
|
||||||
|
|
||||||
if new_path.exists():
|
if new_path.exists():
|
||||||
raise ValueError(f"File {new_filename} already exists")
|
raise ValueError(f"File {new_filename} already exists")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
old_path.rename(new_path)
|
old_path.rename(new_path)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise RuntimeError(f"Error renaming file {old_filename}: {str(e)}")
|
raise RuntimeError(f"Error renaming file {old_filename}: {str(e)}")
|
||||||
|
|||||||
@ -1,41 +1,30 @@
|
|||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
from pydantic import ConfigDict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import os
|
import os
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
"""应用配置"""
|
|
||||||
|
|
||||||
# 基础配置
|
|
||||||
app_name: str = os.getenv("APP_NAME", "Vue3 + Python Notepad")
|
|
||||||
|
|
||||||
# 文件存储配置
|
|
||||||
notes_dir: str = "/app/data"
|
|
||||||
|
|
||||||
# 安全配置
|
|
||||||
file_list_password: str = os.getenv("FILE_LIST_PASSWORD", "admin123")
|
|
||||||
|
|
||||||
# JWT配置(使用固定值,简化配置)
|
|
||||||
jwt_secret_key: str = "default-secret-key-change-in-production"
|
|
||||||
jwt_algorithm: str = "HS256"
|
|
||||||
jwt_expire_minutes: int = 1440 # 24小时
|
|
||||||
|
|
||||||
# 文件限制(使用固定值,简化配置)
|
|
||||||
max_file_size: int = 100000 # 100KB
|
|
||||||
max_filename_length: int = 200
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
env_file = ".env"
|
|
||||||
env_file_encoding = "utf-8"
|
|
||||||
|
|
||||||
# 创建全局设置实例
|
class Settings(BaseSettings):
|
||||||
|
app_name: str = os.getenv("APP_NAME", "Notepad")
|
||||||
|
notes_path: str = "/app/data/notes"
|
||||||
|
allow_origin_regex: str = os.getenv("ALLOW_ORIGIN_REGEX", "")
|
||||||
|
file_list_password: str = os.getenv("FILE_LIST_PASSWORD", "admin123")
|
||||||
|
jwt_secret_key: str = os.getenv("JWT_SECRET_KEY", "default-secret-key-change-in-production")
|
||||||
|
jwt_algorithm: str = "HS256"
|
||||||
|
jwt_expire_minutes: int = 1440
|
||||||
|
max_file_size: int = 100000
|
||||||
|
max_filename_length: int = 200
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
||||||
|
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
# 确保数据目录存在
|
|
||||||
def ensure_data_directory():
|
|
||||||
os.makedirs(settings.notes_dir, exist_ok=True)
|
|
||||||
|
|
||||||
# 在导入时创建目录
|
def ensure_data_directory():
|
||||||
ensure_data_directory()
|
os.makedirs(settings.notes_path, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
ensure_data_directory()
|
||||||
|
|||||||
@ -1,38 +1,20 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
backend:
|
notepad:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: .
|
||||||
dockerfile: Dockerfile
|
args:
|
||||||
container_name: notepad-backend
|
- APP_NAME=${APP_NAME:-Notepad}
|
||||||
restart: unless-stopped
|
container_name: notepad
|
||||||
volumes:
|
|
||||||
- ${FILES_DIR:-./files}:/app/data
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
networks:
|
|
||||||
- notepad-network
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
build:
|
|
||||||
context: ./frontend
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: notepad-frontend
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${EXTERNAL_PORT:-80}:80"
|
- "${PORT:-80}:80"
|
||||||
depends_on:
|
|
||||||
- backend
|
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
networks:
|
volumes:
|
||||||
- notepad-network
|
- ${NOTES_DIR:-./data}:/app/data/notes
|
||||||
|
healthcheck:
|
||||||
networks:
|
test: ["CMD", "curl", "-f", "http://localhost:${PORT:-80}/notepad/health"]
|
||||||
notepad-network:
|
interval: 30s
|
||||||
driver: bridge
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
volumes:
|
start_period: 40s
|
||||||
notepad-data:
|
|
||||||
driver: local
|
|
||||||
10
docker-entrypoint.sh
Executable file
10
docker-entrypoint.sh
Executable file
@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
export BACKEND_PORT=${BACKEND_PORT:-7025}
|
||||||
|
export NOTES_DIR=${NOTES_DIR:-./data}
|
||||||
|
|
||||||
|
mkdir -p /app/data/notes
|
||||||
|
chown -R www-data:www-data /app/data/notes
|
||||||
|
chown -R www-data:www-data /app/frontend/dist
|
||||||
|
|
||||||
|
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
|
||||||
54
docker/default.conf
Normal file
54
docker/default.conf
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /app/frontend/dist;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# 处理前端路由
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 代理 API 请求到后端
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://localhost:7025/;
|
||||||
|
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;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 代理 WebSocket 请求到后端(必须放在/notepad之前)
|
||||||
|
location /notepad/ws {
|
||||||
|
proxy_pass http://localhost:7025;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
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;
|
||||||
|
proxy_read_timeout 86400;
|
||||||
|
proxy_send_timeout 86400;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 代理 /notepad 路径到后端
|
||||||
|
location /notepad {
|
||||||
|
proxy_pass http://localhost:7025;
|
||||||
|
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;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 静态资源缓存
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
44
docker/nginx.conf
Normal file
44
docker/nginx.conf
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
user www-data;
|
||||||
|
worker_processes auto;
|
||||||
|
pid /run/nginx.pid;
|
||||||
|
include /etc/nginx/modules-enabled/*.conf;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
# 日志格式
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log main;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
|
||||||
|
# 基本设置
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
tcp_nodelay on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
types_hash_max_size 2048;
|
||||||
|
|
||||||
|
# Gzip 压缩
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types
|
||||||
|
text/plain
|
||||||
|
text/css
|
||||||
|
text/xml
|
||||||
|
text/javascript
|
||||||
|
application/javascript
|
||||||
|
application/xml+rss
|
||||||
|
application/json;
|
||||||
|
|
||||||
|
include /etc/nginx/conf.d/*.conf;
|
||||||
|
include /etc/nginx/sites-enabled/*;
|
||||||
|
}
|
||||||
22
docker/supervisord.conf
Normal file
22
docker/supervisord.conf
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
[supervisord]
|
||||||
|
nodaemon=true
|
||||||
|
user=root
|
||||||
|
logfile=/var/log/supervisor/supervisord.log
|
||||||
|
pidfile=/var/run/supervisord.pid
|
||||||
|
childlogdir=/var/log/supervisor
|
||||||
|
|
||||||
|
[program:nginx]
|
||||||
|
command=nginx -g "daemon off;"
|
||||||
|
stdout_logfile=/var/log/supervisor/nginx.log
|
||||||
|
stderr_logfile=/var/log/supervisor/nginx.log
|
||||||
|
autorestart=true
|
||||||
|
priority=10
|
||||||
|
|
||||||
|
[program:backend]
|
||||||
|
command=python3 main.py
|
||||||
|
directory=/app
|
||||||
|
stdout_logfile=/var/log/supervisor/backend.log
|
||||||
|
stderr_logfile=/var/log/supervisor/backend.log
|
||||||
|
autorestart=true
|
||||||
|
priority=20
|
||||||
|
environment=PORT=7025
|
||||||
@ -1,224 +0,0 @@
|
|||||||
# Vue3 + Python Notepad 应用 - Brainstorming 会话记录
|
|
||||||
|
|
||||||
**会话主题**: Vue3 + Python后端简单Notepad应用
|
|
||||||
**会话日期**: 2025-12-18
|
|
||||||
**目标**: 为核心功能 + 自动保存 + 文件列表页面生成创意
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Question Storming (问题风暴)
|
|
||||||
|
|
||||||
**技术说明**: 先生成关于需求、实现、用户体验的所有问题,而不是直接寻找答案。
|
|
||||||
|
|
||||||
**生成的问题列表**:
|
|
||||||
- 用户打开页面时,应该看到什么样的空白文档?
|
|
||||||
- 自动保存的频率应该是多少?
|
|
||||||
- 如果用户同时打开多个标签页编辑同一个文件怎么办?
|
|
||||||
- txt文件应该存储在什么目录结构?
|
|
||||||
- URL中的文件名应该支持中文吗?
|
|
||||||
- 文件列表页面的口令应该如何安全验证?
|
|
||||||
- 文件大小限制10万字符是否合理?
|
|
||||||
- 如何处理文件名冲突?
|
|
||||||
- 自动保存失败时如何通知用户?
|
|
||||||
- 是否需要支持文件删除功能?
|
|
||||||
|
|
||||||
## 2. First Principles Thinking (第一性原理思维)
|
|
||||||
|
|
||||||
**技术说明**: 将应用拆解到最基本的、不可再分的核心要素,然后从底层重新构建解决方案。
|
|
||||||
|
|
||||||
**核心功能拆解**:
|
|
||||||
|
|
||||||
### 高层次功能要素(简化视角):
|
|
||||||
|
|
||||||
基于用户的洞察,我们将技术实现细节抽象化,聚焦于核心功能需求:
|
|
||||||
|
|
||||||
**1. 实时文档编辑与持久化**
|
|
||||||
- 用户在浏览器中编辑文本
|
|
||||||
- 内容自动保存到服务器文件系统
|
|
||||||
- 使用WebSocket或类似技术实现实时同步
|
|
||||||
- 框架负责处理编辑、存储、保存的细节
|
|
||||||
|
|
||||||
**2. URL驱动的文档访问**
|
|
||||||
- URL路径映射到特定文件
|
|
||||||
- 文件不存在时自动创建
|
|
||||||
- 文件存在时加载内容
|
|
||||||
- 支持通过URL分享文档
|
|
||||||
|
|
||||||
**3. 文件列表与管理**
|
|
||||||
- 展示所有txt文件列表
|
|
||||||
- 后端口令验证保护
|
|
||||||
- 点击文件进入编辑
|
|
||||||
- 可选的文件操作(删除、重命名)
|
|
||||||
|
|
||||||
**关键洞察**:
|
|
||||||
- 使用现代框架(Vue3 + Python后端)可以简化文档编辑、存储、自动保存的实现
|
|
||||||
- WebSocket或HTTP长轮询可以实现实时自动保存
|
|
||||||
- 不需要关心底层细节,框架会处理光标、字符存储、变更检测等
|
|
||||||
- 聚焦于功能需求而非技术实现细节
|
|
||||||
|
|
||||||
**要素之间的依赖关系**:
|
|
||||||
- 文档编辑 ↔ 自动保存:实时同步机制
|
|
||||||
- URL访问 ↔ 文件存储:文件名映射和存在性检查
|
|
||||||
- 文件列表 ↔ 文件存储:目录遍历和文件操作
|
|
||||||
|
|
||||||
## 3. What If Scenarios (假设场景分析)
|
|
||||||
|
|
||||||
**技术说明**: 探索边界情况、潜在问题和异常场景,提前识别风险。
|
|
||||||
|
|
||||||
### 需要优先处理的场景:
|
|
||||||
|
|
||||||
#### 文件相关场景
|
|
||||||
- **What if**: 用户访问的URL文件名包含特殊字符(如 `/`, `\\`, `..`)?
|
|
||||||
- **What if**: 两个用户同时创建同名文件(同一用户可能开多个标签)?
|
|
||||||
- **What if**: 文件达到10万字符限制后继续输入?
|
|
||||||
- **What if**: 文件在编辑过程中被外部删除或修改?
|
|
||||||
- **What if**: 磁盘空间不足,无法保存?
|
|
||||||
|
|
||||||
#### 自动保存场景
|
|
||||||
- **What if**: 自动保存过程中网络断开?
|
|
||||||
- **What if**: 用户快速连续输入,触发频繁保存?
|
|
||||||
- **What if**: 保存失败(权限问题、磁盘满)?
|
|
||||||
|
|
||||||
#### URL访问场景
|
|
||||||
- **What if**: 用户输入了不存在的文件名格式(如包含空格、中文)?
|
|
||||||
- **What if**: URL中的文件名与现有文件大小写不同(如 `Note.txt` vs `note.txt`)?
|
|
||||||
- **What if**: 用户直接访问文件列表页面而不输入口令?
|
|
||||||
|
|
||||||
#### 安全性场景
|
|
||||||
- **What if**: 有人暴力破解文件列表页面的口令?
|
|
||||||
- **What if**: 用户尝试访问系统文件(如 `/etc/passwd`)?
|
|
||||||
|
|
||||||
#### 用户体验场景
|
|
||||||
- **What if**: 用户意外关闭浏览器标签页?
|
|
||||||
- **What if**: 文件列表很长(几百个文件)?
|
|
||||||
|
|
||||||
### 可忽略的场景(用户确认):
|
|
||||||
- ❌ 用户想找回之前编辑的内容(版本历史)
|
|
||||||
- ❌ 用户想重命名或删除文件
|
|
||||||
- ❌ 用户想手动保存并看到保存确认
|
|
||||||
|
|
||||||
### 优先级评估:
|
|
||||||
**高优先级**: 特殊字符处理、文件大小限制、网络断开、安全访问控制
|
|
||||||
**中优先级**: 大小写敏感、文件列表过长、快速输入触发频率
|
|
||||||
**低优先级**: 磁盘空间不足、文件被外部修改(可接受简单错误提示)
|
|
||||||
|
|
||||||
## 4. Mind Mapping (功能思维导图)
|
|
||||||
|
|
||||||
**技术说明**: 将所有想法组织成清晰的功能结构图,展示功能之间的关系和层次。
|
|
||||||
|
|
||||||
```
|
|
||||||
Notepad应用 (核心目标:简单、实时、URL驱动)
|
|
||||||
│
|
|
||||||
├── 核心功能模块
|
|
||||||
│ │
|
|
||||||
│ ├── 文档编辑模块
|
|
||||||
│ │ ├── 文本输入/显示 (Vue3组件)
|
|
||||||
│ │ ├── 实时内容同步 (WebSocket/HTTP)
|
|
||||||
│ │ └── 编辑状态检测 (变更检测)
|
|
||||||
│ │
|
|
||||||
│ ├── 自动保存模块
|
|
||||||
│ │ ├── 触发机制 (定时器 + 内容变更)
|
|
||||||
│ │ ├── 文件写入 (Python后端)
|
|
||||||
│ │ └── 失败处理 (错误提示)
|
|
||||||
│ │
|
|
||||||
│ └── URL路由模块
|
|
||||||
│ ├── 路径解析 (提取文件名)
|
|
||||||
│ ├── 文件存在检查
|
|
||||||
│ ├── 文件加载 (存在时)
|
|
||||||
│ └── 文件创建 (不存在时)
|
|
||||||
│
|
|
||||||
├── 文件管理模块
|
|
||||||
│ │
|
|
||||||
│ ├── 文件列表页面
|
|
||||||
│ │ ├── 目录遍历 (读取所有txt文件)
|
|
||||||
│ │ ├── 列表展示 (文件名、创建时间)
|
|
||||||
│ │ └── 点击跳转 (URL链接)
|
|
||||||
│ │
|
|
||||||
│ └── 安全控制
|
|
||||||
│ ├── 后端口令验证 (POST请求)
|
|
||||||
│ ├── 会话管理 (简单令牌)
|
|
||||||
│ └── 失败处理 (拒绝访问)
|
|
||||||
│
|
|
||||||
├── 边界处理模块
|
|
||||||
│ │
|
|
||||||
│ ├── 输入验证
|
|
||||||
│ │ ├── 特殊字符过滤 (/、\\、..)
|
|
||||||
│ │ ├── 文件名长度限制
|
|
||||||
│ │ └── URL编码处理
|
|
||||||
│ │
|
|
||||||
│ ├── 文件限制
|
|
||||||
│ │ ├── 大小限制 (10万字符)
|
|
||||||
│ │ ├── 超出处理 (提示+阻止输入)
|
|
||||||
│ │ └── 磁盘空间检查
|
|
||||||
│ │
|
|
||||||
│ └── 异常处理
|
|
||||||
│ ├── 网络断开 (重连机制)
|
|
||||||
│ ├── 文件被占用 (冲突提示)
|
|
||||||
│ └── 权限错误 (访问拒绝)
|
|
||||||
│
|
|
||||||
└── 技术栈
|
|
||||||
│
|
|
||||||
├── 前端 (Vue3)
|
|
||||||
│ ├── 文本编辑器组件
|
|
||||||
│ ├── 路由管理 (Vue Router)
|
|
||||||
│ └── API调用 (Axios/Fetch)
|
|
||||||
│
|
|
||||||
├── 后端 (Python)
|
|
||||||
│ ├── Web框架 (FastAPI/Flask)
|
|
||||||
│ ├── 文件操作 (OS模块)
|
|
||||||
│ └── WebSocket支持 (实时通信)
|
|
||||||
│
|
|
||||||
└── 部署 (Docker)
|
|
||||||
├── 物理机: Ubuntu系统
|
|
||||||
├── Docker镜像: Python环境
|
|
||||||
└── 静态文件代理: Nginx或类似工具
|
|
||||||
```
|
|
||||||
|
|
||||||
**思维导图分析**:
|
|
||||||
|
|
||||||
1. **核心功能模块**是最关键的三个互相依赖的模块
|
|
||||||
2. **文件管理模块**提供了额外的列表查看功能
|
|
||||||
3. **边界处理模块**确保了应用的健壮性
|
|
||||||
4. **技术栈**选择了现代且简单的框架组合
|
|
||||||
|
|
||||||
## 5. 实现优先级规划
|
|
||||||
|
|
||||||
基于用户需求,建议按照以下顺序实现:
|
|
||||||
|
|
||||||
### Phase 1: 网页基本功能 (最高优先级)
|
|
||||||
- Vue3文本编辑器组件
|
|
||||||
- 基础UI布局和样式
|
|
||||||
- 文本输入和显示功能
|
|
||||||
|
|
||||||
### Phase 2: 后端接口开发
|
|
||||||
- Python Web框架搭建 (FastAPI/Flask)
|
|
||||||
- 文件读写API
|
|
||||||
- URL路由处理
|
|
||||||
- WebSocket实时通信
|
|
||||||
|
|
||||||
### Phase 3: 异常和边界条件完善
|
|
||||||
- 特殊字符过滤和验证
|
|
||||||
- 文件大小限制检查
|
|
||||||
- 错误处理和用户提示
|
|
||||||
- 文件列表页面后端口令验证
|
|
||||||
|
|
||||||
### Phase 4: 部署配置
|
|
||||||
- Docker镜像配置 (Python环境)
|
|
||||||
- Nginx静态文件代理配置
|
|
||||||
- Docker Compose编排
|
|
||||||
- 生产环境测试
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 会话总结
|
|
||||||
|
|
||||||
**使用的技术**: Question Storming → First Principles Thinking → What If Scenarios → Mind Mapping
|
|
||||||
|
|
||||||
**生成的核心洞察**:
|
|
||||||
1. 使用现代框架可以简化文档编辑、存储、自动保存的实现
|
|
||||||
2. 聚焦于功能需求而非底层技术细节
|
|
||||||
3. 需要优先处理特殊字符、文件大小限制、网络安全等边界情况
|
|
||||||
4. 实现顺序:前端功能 → 后端接口 → 异常处理 → 部署
|
|
||||||
|
|
||||||
**下一步行动**: 基于本brainstorming成果,开始Phase 1的网页基本功能开发
|
|
||||||
|
|
||||||
@ -1,435 +0,0 @@
|
|||||||
# Vue3 + Python Notepad 应用 - UI/UX 规格文档
|
|
||||||
|
|
||||||
**文档版本**: 1.0
|
|
||||||
**创建日期**: 2025-12-18
|
|
||||||
**作者**: BMad UX Expert
|
|
||||||
**项目状态**: Greenfield (全新开发)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Introduction
|
|
||||||
|
|
||||||
This document defines the user experience goals, information architecture, user flows, and visual design specifications for Vue3 + Python Notepad 应用's user interface. It serves as the foundation for visual design and frontend development, ensuring a cohesive and user-centered experience.
|
|
||||||
|
|
||||||
### 1.1 Overall UX Goals & Principles
|
|
||||||
|
|
||||||
#### Target User Personas
|
|
||||||
|
|
||||||
- **日常记录用户**: 需要快速记录想法、笔记的普通用户,重视简单易用
|
|
||||||
- **技术工作者**: 开发者、研究人员等需要频繁记录技术内容,重视效率和可靠性
|
|
||||||
- **团队协作者**: 小型团队成员,需要共享和访问文本文件,重视访问控制和文件管理
|
|
||||||
|
|
||||||
#### Usability Goals
|
|
||||||
|
|
||||||
- **易学性**: 新用户无需阅读文档即可在30秒内开始编辑
|
|
||||||
- **效率**: 熟练用户可以通过URL直接访问文件,无需额外点击
|
|
||||||
- **错误预防**: 自动保存机制防止数据丢失,文件名验证防止路径遍历攻击
|
|
||||||
- **可记忆性**: 界面设计遵循常见文本编辑器模式,用户可凭直觉操作
|
|
||||||
|
|
||||||
#### Design Principles
|
|
||||||
|
|
||||||
1. **极简主义** - 去除所有非必要元素,聚焦于文本编辑核心体验
|
|
||||||
2. **即时响应** - 每个用户操作都应有明确的视觉反馈
|
|
||||||
3. **无干扰编辑** - 界面元素不应分散用户对内容的注意力
|
|
||||||
4. **渐进式披露** - 只在需要时显示辅助功能
|
|
||||||
5. **无障碍优先** - 从设计开始就确保所有用户都能使用
|
|
||||||
|
|
||||||
### 1.2 Change Log
|
|
||||||
|
|
||||||
| 日期 | 版本 | 描述 | 作者 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| 2025-12-18 | 1.0 | 初始UI/UX规格文档创建 | BMad UX Expert |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Information Architecture (IA)
|
|
||||||
|
|
||||||
### 2.1 Site Map / Screen Inventory
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TD
|
|
||||||
A[首页/编辑页面] --> B[文件列表页面]
|
|
||||||
A --> C[错误页面]
|
|
||||||
B --> D[口令验证界面]
|
|
||||||
B --> E[文件列表展示]
|
|
||||||
E --> A
|
|
||||||
|
|
||||||
A --> F[编辑器区域]
|
|
||||||
A --> G[自动保存状态指示器]
|
|
||||||
A --> H[文件名显示]
|
|
||||||
|
|
||||||
C --> I[网络错误提示]
|
|
||||||
C --> J[文件访问错误提示]
|
|
||||||
C --> K[权限错误提示]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 Navigation Structure
|
|
||||||
|
|
||||||
**Primary Navigation:** URL驱动的页面导航,通过修改URL直接访问不同文件
|
|
||||||
|
|
||||||
**Secondary Navigation:** 文件列表页面中的文件链接,点击直接跳转到对应编辑页面
|
|
||||||
|
|
||||||
**Breadcrumb Strategy:** 简单的路径显示,如 `Home > filename.txt`,仅在文件列表页面显示
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. User Flows
|
|
||||||
|
|
||||||
### 3.1 Flow 1: 新建文件编辑
|
|
||||||
|
|
||||||
**User Goal:** 创建并编辑一个新的文本文件
|
|
||||||
|
|
||||||
**Entry Points:**
|
|
||||||
- 直接访问 `/notes/newfile.txt` (newfile.txt不存在)
|
|
||||||
- 从文件列表页面点击"新建文件"按钮
|
|
||||||
|
|
||||||
**Success Criteria:**
|
|
||||||
- 用户可以立即开始输入内容
|
|
||||||
- 内容自动保存,无需手动操作
|
|
||||||
- 文件成功创建并可通过URL访问
|
|
||||||
|
|
||||||
#### Flow Diagram
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TD
|
|
||||||
A[用户访问不存在的文件URL] --> B{文件存在?}
|
|
||||||
B -->|否| C[创建空白文件]
|
|
||||||
B -->|是| D[加载现有内容]
|
|
||||||
C --> E[显示编辑界面]
|
|
||||||
D --> E
|
|
||||||
E --> F[用户输入内容]
|
|
||||||
F --> G{内容变化?}
|
|
||||||
G -->|是| H[500ms后触发自动保存]
|
|
||||||
G -->|否| I[继续监听]
|
|
||||||
H --> J[保存到服务器]
|
|
||||||
J --> K[显示"已保存"状态]
|
|
||||||
K --> I
|
|
||||||
I --> F
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Edge Cases & Error Handling:
|
|
||||||
- 网络断开时显示"连接断开"状态,自动重连后恢复保存
|
|
||||||
- 保存失败时显示"保存失败"提示,提供重试按钮
|
|
||||||
- 文件名包含非法字符时显示错误提示
|
|
||||||
|
|
||||||
#### Notes:
|
|
||||||
- 文件名大小写敏感,遵循Unix文件系统规则
|
|
||||||
- 自动保存防抖时间设置为500ms,平衡实时性和性能
|
|
||||||
|
|
||||||
### 3.2 Flow 2: 文件列表访问
|
|
||||||
|
|
||||||
**User Goal:** 查看并访问所有已保存的文本文件
|
|
||||||
|
|
||||||
**Entry Points:**
|
|
||||||
- 直接访问 `/file-list` URL
|
|
||||||
- 编辑页面中的"文件列表"链接
|
|
||||||
|
|
||||||
**Success Criteria:**
|
|
||||||
- 用户通过口令验证后能看到所有文件列表
|
|
||||||
- 点击文件名能直接跳转到编辑页面
|
|
||||||
- 文件按创建时间倒序排列
|
|
||||||
|
|
||||||
#### Flow Diagram
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TD
|
|
||||||
A[用户访问文件列表页面] --> B{已验证?}
|
|
||||||
B -->|否| C[显示口令输入表单]
|
|
||||||
B -->|是| D[显示文件列表]
|
|
||||||
C --> E[用户输入口令]
|
|
||||||
E --> F[验证口令]
|
|
||||||
F --> G{口令正确?}
|
|
||||||
G -->|是| H[保存令牌到本地]
|
|
||||||
G -->|否| I[显示错误提示]
|
|
||||||
H --> D
|
|
||||||
I --> C
|
|
||||||
D --> J[用户点击文件名]
|
|
||||||
J --> K[跳转到编辑页面]
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Edge Cases & Error Handling:
|
|
||||||
- 口令验证失败时显示明确的错误信息
|
|
||||||
- 令牌过期时重新要求验证
|
|
||||||
- 文件列表为空时显示友好提示
|
|
||||||
|
|
||||||
#### Notes:
|
|
||||||
- 令牌有效期设置为24小时,平衡安全性和便利性
|
|
||||||
- 分页加载策略:每页显示50个文件,支持滚动加载
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Wireframes & Mockups
|
|
||||||
|
|
||||||
**Primary Design Files:** 设计将在开发过程中通过代码实现,不使用外部设计工具
|
|
||||||
|
|
||||||
### 4.1 Key Screen Layouts
|
|
||||||
|
|
||||||
#### Screen 1: 文本编辑页面
|
|
||||||
|
|
||||||
**Purpose:** 提供无干扰的文本编辑体验
|
|
||||||
|
|
||||||
**Key Elements:**
|
|
||||||
- 顶部栏:文件名显示 + 自动保存状态指示器
|
|
||||||
- 主体区域:全屏文本编辑器
|
|
||||||
- 右上角:文件列表链接(小图标)
|
|
||||||
|
|
||||||
**Interaction Notes:**
|
|
||||||
- 页面加载时自动聚焦到编辑器
|
|
||||||
- 保存状态实时更新,不干扰编辑
|
|
||||||
- 支持键盘快捷键(Ctrl+S 手动保存)
|
|
||||||
|
|
||||||
#### Screen 2: 文件列表页面
|
|
||||||
|
|
||||||
**Purpose:** 展示所有文件并提供快速访问
|
|
||||||
|
|
||||||
**Key Elements:**
|
|
||||||
- 顶部:标题 + 口令验证状态
|
|
||||||
- 主体:文件列表(文件名、创建时间)
|
|
||||||
- 底部:分页控件/加载更多按钮
|
|
||||||
|
|
||||||
**Interaction Notes:**
|
|
||||||
- 文件名可点击,跳转到编辑页面
|
|
||||||
- 支持按文件名搜索/过滤
|
|
||||||
- 响应式设计,适配移动设备
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Component Library / Design System
|
|
||||||
|
|
||||||
**Design System Approach:** 使用轻量级设计系统,基于原生HTML元素和CSS,避免过度工程化
|
|
||||||
|
|
||||||
### 5.1 Core Components
|
|
||||||
|
|
||||||
#### Component: 编辑器 (TextEditor)
|
|
||||||
|
|
||||||
**Purpose:** 提供文本输入和编辑功能
|
|
||||||
|
|
||||||
**Variants:**
|
|
||||||
- 全屏模式(默认)
|
|
||||||
- 紧凑模式(移动设备)
|
|
||||||
|
|
||||||
**States:**
|
|
||||||
- 正常编辑状态
|
|
||||||
- 保存中状态
|
|
||||||
- 保存失败状态
|
|
||||||
- 只读状态(文件被锁定时)
|
|
||||||
|
|
||||||
**Usage Guidelines:**
|
|
||||||
- 占据主要视口空间
|
|
||||||
- 使用等宽字体提高可读性
|
|
||||||
- 支持Tab缩进
|
|
||||||
|
|
||||||
#### Component: 状态指示器 (StatusIndicator)
|
|
||||||
|
|
||||||
**Purpose:** 显示系统状态和操作反馈
|
|
||||||
|
|
||||||
**Variants:**
|
|
||||||
- 保存状态(已保存/保存中/保存失败)
|
|
||||||
- 网络状态(已连接/断开连接)
|
|
||||||
- 错误提示
|
|
||||||
|
|
||||||
**States:**
|
|
||||||
- 正常状态(绿色)
|
|
||||||
- 处理中状态(黄色/动画)
|
|
||||||
- 错误状态(红色)
|
|
||||||
|
|
||||||
**Usage Guidelines:**
|
|
||||||
- 使用颜色和图标组合
|
|
||||||
- 状态变化时添加过渡动画
|
|
||||||
- 位置固定,不随页面滚动
|
|
||||||
|
|
||||||
#### Component: 文件列表项 (FileListItem)
|
|
||||||
|
|
||||||
**Purpose:** 在文件列表中显示单个文件信息
|
|
||||||
|
|
||||||
**Variants:**
|
|
||||||
- 标准列表项
|
|
||||||
- 搜索高亮项
|
|
||||||
|
|
||||||
**States:**
|
|
||||||
- 正常状态
|
|
||||||
- 悬停状态
|
|
||||||
- 焦点状态
|
|
||||||
|
|
||||||
**Usage Guidelines:**
|
|
||||||
- 显示文件名和创建时间
|
|
||||||
- 点击区域覆盖整个列表项
|
|
||||||
- 支持键盘导航
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Branding & Style Guide
|
|
||||||
|
|
||||||
**Brand Guidelines:** 采用现代、简洁的设计风格,强调内容而非装饰
|
|
||||||
|
|
||||||
### 6.1 Color Palette
|
|
||||||
|
|
||||||
| Color Type | Hex Code | Usage |
|
|
||||||
|------------|----------|-------|
|
|
||||||
| Primary | #2c3e50 | 主要文本、重要交互元素 |
|
|
||||||
| Secondary | #7f8c8d | 次要文本、辅助信息 |
|
|
||||||
| Accent | #3498db | 链接、按钮、强调元素 |
|
|
||||||
| Success | #27ae60 | 成功状态、保存完成 |
|
|
||||||
| Warning | #f39c12 | 警告信息、注意提示 |
|
|
||||||
| Error | #e74c3c | 错误状态、失败提示 |
|
|
||||||
| Neutral | #ecf0f1 | 背景、边框、分隔线 |
|
|
||||||
|
|
||||||
### 6.2 Typography
|
|
||||||
|
|
||||||
#### Font Families
|
|
||||||
- **Primary:** -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif
|
|
||||||
- **Secondary:** "Helvetica Neue", Arial, sans-serif
|
|
||||||
- **Monospace:** SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace
|
|
||||||
|
|
||||||
#### Type Scale
|
|
||||||
|
|
||||||
| Element | Size | Weight | Line Height |
|
|
||||||
|---------|------|--------|-------------|
|
|
||||||
| H1 | 24px | 600 | 1.4 |
|
|
||||||
| H2 | 20px | 600 | 1.4 |
|
|
||||||
| H3 | 16px | 600 | 1.4 |
|
|
||||||
| Body | 14px | 400 | 1.5 |
|
|
||||||
| Small | 12px | 400 | 1.4 |
|
|
||||||
|
|
||||||
### 6.3 Iconography
|
|
||||||
|
|
||||||
**Icon Library:** 使用简单的SVG图标,避免依赖外部图标库
|
|
||||||
|
|
||||||
**Usage Guidelines:**
|
|
||||||
- 图标大小统一为16px或24px
|
|
||||||
- 使用相同的线条粗细(2px)
|
|
||||||
- 保持简洁的视觉风格
|
|
||||||
|
|
||||||
### 6.4 Spacing & Layout
|
|
||||||
|
|
||||||
**Grid System:** 使用CSS Flexbox布局,不依赖固定网格系统
|
|
||||||
|
|
||||||
**Spacing Scale:** 基于8px的间距系统
|
|
||||||
- 小间距:8px
|
|
||||||
- 中等间距:16px
|
|
||||||
- 大间距:24px
|
|
||||||
- 特大间距:32px
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Accessibility Requirements
|
|
||||||
|
|
||||||
### 7.1 Compliance Target
|
|
||||||
|
|
||||||
**Standard:** WCAG 2.1 AA
|
|
||||||
|
|
||||||
### 7.2 Key Requirements
|
|
||||||
|
|
||||||
**Visual:**
|
|
||||||
- Color contrast ratios: 文本对比度至少4.5:1,大文本至少3:1
|
|
||||||
- Focus indicators: 所有交互元素有清晰的焦点指示器
|
|
||||||
- Text sizing: 支持浏览器缩放至200%而不影响功能
|
|
||||||
|
|
||||||
**Interaction:**
|
|
||||||
- Keyboard navigation: 所有功能可通过键盘访问
|
|
||||||
- Screen reader support: 适当的ARIA标签和语义化HTML
|
|
||||||
- Touch targets: 最小触摸目标44px × 44px
|
|
||||||
|
|
||||||
**Content:**
|
|
||||||
- Alternative text: 所有有意义的图像提供alt文本
|
|
||||||
- Heading结构: 正确的标题层级结构
|
|
||||||
- Form labels: 所有表单元素有关联的标签
|
|
||||||
|
|
||||||
### 7.3 Testing Strategy
|
|
||||||
|
|
||||||
- 使用axe-core自动化测试工具
|
|
||||||
- 键盘导航测试
|
|
||||||
- 屏幕阅读器测试(NVDA, VoiceOver)
|
|
||||||
- 色彩对比度测试
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Responsiveness Strategy
|
|
||||||
|
|
||||||
### 8.1 Breakpoints
|
|
||||||
|
|
||||||
| Breakpoint | Min Width | Max Width | Target Devices |
|
|
||||||
|------------|-----------|-----------|----------------|
|
|
||||||
| Mobile | 320px | 767px | 手机设备 |
|
|
||||||
| Tablet | 768px | 1023px | 平板设备 |
|
|
||||||
| Desktop | 1024px | 1439px | 笔记本、桌面显示器 |
|
|
||||||
| Wide | 1440px | - | 大屏显示器 |
|
|
||||||
|
|
||||||
### 8.2 Adaptation Patterns
|
|
||||||
|
|
||||||
**Layout Changes:**
|
|
||||||
- 移动设备隐藏文件名显示,使用汉堡菜单
|
|
||||||
- 平板设备保持单列布局,调整间距
|
|
||||||
|
|
||||||
**Navigation Changes:**
|
|
||||||
- 移动设备使用底部导航栏
|
|
||||||
- 桌面设备使用顶部导航
|
|
||||||
|
|
||||||
**Content Priority:**
|
|
||||||
- 移动设备优先显示编辑器,辅助信息折叠
|
|
||||||
- 大屏设备并排显示更多信息
|
|
||||||
|
|
||||||
**Interaction Changes:**
|
|
||||||
- 移动设备增加触摸友好的按钮尺寸
|
|
||||||
- 桌面设备支持右键菜单和快捷键
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Animation & Micro-interactions
|
|
||||||
|
|
||||||
### 9.1 Motion Principles
|
|
||||||
|
|
||||||
- **功能性优先**: 动画服务于功能,而非装饰
|
|
||||||
- **快速响应**: 动画时长不超过200ms
|
|
||||||
- **自然流畅**: 使用缓动函数模拟自然运动
|
|
||||||
- **尊重用户偏好**: 遵循系统的减少动画设置
|
|
||||||
|
|
||||||
### 9.2 Key Animations
|
|
||||||
|
|
||||||
- **保存状态切换**: 透明度变化(Duration: 150ms, Easing: ease-out)
|
|
||||||
- **错误提示滑入**: 从顶部滑入(Duration: 200ms, Easing: ease-out)
|
|
||||||
- **文件列表项悬停**: 背景色渐变(Duration: 100ms, Easing: ease-in-out)
|
|
||||||
- **页面切换**: 淡入淡出(Duration: 150ms, Easing: ease-out)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Performance Considerations
|
|
||||||
|
|
||||||
### 10.1 Performance Goals
|
|
||||||
|
|
||||||
- **Page Load**: 首屏加载时间不超过2秒
|
|
||||||
- **Interaction Response**: 交互响应时间不超过100ms
|
|
||||||
- **Animation FPS**: 动画帧率保持60fps
|
|
||||||
|
|
||||||
### 10.2 Design Strategies
|
|
||||||
|
|
||||||
- 使用CSS变量实现主题切换,减少重绘
|
|
||||||
- 优化字体加载策略,使用font-display: swap
|
|
||||||
- 实现虚拟滚动处理大量文件列表
|
|
||||||
- 使用Intersection Observer实现懒加载
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Next Steps
|
|
||||||
|
|
||||||
### 11.1 Immediate Actions
|
|
||||||
|
|
||||||
1. 与开发团队确认技术可行性
|
|
||||||
2. 创建原型验证核心交互流程
|
|
||||||
3. 准备设计系统的基础组件
|
|
||||||
4. 制定前端架构设计方案
|
|
||||||
|
|
||||||
### 11.2 Design Handoff Checklist
|
|
||||||
|
|
||||||
- [x] 所有用户流程已文档化
|
|
||||||
- [x] 组件清单已完成
|
|
||||||
- [x] 无障碍需求已定义
|
|
||||||
- [x] 响应式策略已明确
|
|
||||||
- [x] 品牌指导原则已纳入
|
|
||||||
- [x] 性能目标已建立
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Checklist Results
|
|
||||||
|
|
||||||
UI/UX规格文档已完成,包含所有必要的用户流程、组件定义、设计规范和实施指导。文档可作为前端开发的完整指导,确保用户体验的一致性和质量。
|
|
||||||
File diff suppressed because it is too large
Load Diff
399
docs/prd.md
399
docs/prd.md
@ -1,399 +0,0 @@
|
|||||||
# Vue3 + Python Notepad 应用 - 产品需求文档 (PRD)
|
|
||||||
|
|
||||||
**文档版本**: 1.0
|
|
||||||
**创建日期**: 2025-12-18
|
|
||||||
**作者**: BMad Analyst
|
|
||||||
**项目状态**: Greenfield (全新开发)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Goals and Background Context
|
|
||||||
|
|
||||||
### 1.1 Goals
|
|
||||||
|
|
||||||
- 提供一个简单、实时的在线文本编辑器,通过URL直接访问特定文件
|
|
||||||
- 实现自动保存功能,无需用户手动操作
|
|
||||||
- 提供文件列表页面,方便用户管理和访问所有文档
|
|
||||||
- 确保应用安全、稳定,能够处理各种边界情况
|
|
||||||
- 支持在Ubuntu物理机上通过Docker部署
|
|
||||||
|
|
||||||
### 1.2 Background Context
|
|
||||||
|
|
||||||
本项目旨在开发一个轻量级的在线Notepad应用,结合Vue3前端和Python后端技术栈。用户可以通过URL直接访问和编辑文本文件,所有更改会自动保存到服务器。应用设计遵循简单、实时、URL驱动的理念,适合个人或小型团队快速记录和分享文本内容。
|
|
||||||
|
|
||||||
该应用解决了传统文本编辑器需要手动保存、文件共享不便的问题,通过浏览器即可访问,无需安装额外软件。特别适合需要快速记录、跨设备访问文本内容的场景。
|
|
||||||
|
|
||||||
### 1.3 Change Log
|
|
||||||
|
|
||||||
| 日期 | 版本 | 描述 | 作者 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| 2025-12-18 | 1.0 | 初始PRD文档创建 | BMad Analyst |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Requirements
|
|
||||||
|
|
||||||
### 2.1 Functional Requirements
|
|
||||||
|
|
||||||
**FR1**: 用户可以通过URL路径访问特定文本文件,URL格式为 `/notes/{filename}.txt`
|
|
||||||
|
|
||||||
**FR2**: 如果访问的文件不存在,系统自动创建空白文件;如果文件已存在,加载并显示内容
|
|
||||||
|
|
||||||
**FR3**: 提供文本编辑区域,支持用户输入和编辑内容,文件大小限制为10万字符
|
|
||||||
|
|
||||||
**FR4**: 实现自动保存功能,当用户停止输入或内容发生变化时自动保存到服务器
|
|
||||||
|
|
||||||
**FR5**: 提供文件列表页面 `/file-list`,显示所有txt文件的列表(文件名、创建时间)
|
|
||||||
|
|
||||||
**FR6**: 文件列表页面需要后端口令验证,只有通过POST请求提供正确口令才能访问
|
|
||||||
|
|
||||||
**FR7**: 从文件列表页面点击文件名可以直接跳转到对应的编辑页面
|
|
||||||
|
|
||||||
**FR8**: 对URL中的文件名进行验证和过滤,阻止包含特殊字符(如 `/`, `\\`, `..`)的文件名
|
|
||||||
|
|
||||||
**FR9**: 当文件达到10万字符限制时,阻止用户继续输入并显示提示信息
|
|
||||||
|
|
||||||
**FR10**: 提供适当的错误处理和用户提示,包括网络错误、保存失败、文件访问错误等
|
|
||||||
|
|
||||||
**FR11**: 支持中文文件名,正确处理URL编码和解码
|
|
||||||
|
|
||||||
**FR12**: 文件列表页面支持分页或滚动加载,以处理大量文件的情况
|
|
||||||
|
|
||||||
### 2.2 Non-Functional Requirements
|
|
||||||
|
|
||||||
**NFR1**: 应用响应时间应在100ms以内(不包括网络延迟)
|
|
||||||
|
|
||||||
**NFR2**: 自动保存操作应在用户停止输入后500ms内触发
|
|
||||||
|
|
||||||
**NFR3**: 系统应支持至少100个并发用户编辑不同文件
|
|
||||||
|
|
||||||
**NFR4**: 应用应部署在Docker容器中,支持在Ubuntu物理机上运行
|
|
||||||
|
|
||||||
**NFR5**: 前端资源应优化加载,首屏加载时间不超过2秒
|
|
||||||
|
|
||||||
**NFR6**: 应用日志应记录关键操作(文件创建、保存、访问)以便故障排查
|
|
||||||
|
|
||||||
**NFR7**: 系统应具备基本的错误恢复能力,网络断开后能自动重连
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. User Interface Design Goals
|
|
||||||
|
|
||||||
### 3.1 Overall UX Vision
|
|
||||||
|
|
||||||
创建一个简洁、直观的文本编辑体验,界面设计遵循极简主义原则。用户打开页面即可立即开始编辑,无需复杂的设置或学习成本。编辑区域占据主要空间,提供舒适的阅读和写作环境。
|
|
||||||
|
|
||||||
### 3.2 Key Interaction Paradigms
|
|
||||||
|
|
||||||
- **直接编辑**: 页面加载完成后,光标自动定位到编辑区域,用户可以立即开始输入
|
|
||||||
- **实时反馈**: 自动保存状态通过视觉指示器显示(如"已保存"、"保存中...")
|
|
||||||
- **URL驱动导航**: 用户可以通过修改URL直接访问不同文件,支持浏览器前进/后退
|
|
||||||
- **无缝文件管理**: 从文件列表到编辑页面的切换应流畅自然,保持用户体验一致性
|
|
||||||
|
|
||||||
### 3.3 Core Screens and Views
|
|
||||||
|
|
||||||
1. **文本编辑页面**: 主要编辑界面,包含文本编辑区域、文件名显示、自动保存状态指示器
|
|
||||||
2. **文件列表页面**: 显示所有可用文件的列表,包含文件名、创建时间,以及口令输入表单
|
|
||||||
3. **错误提示页面**: 显示各种错误信息,如文件访问被拒绝、文件不存在等
|
|
||||||
|
|
||||||
### 3.4 Accessibility
|
|
||||||
|
|
||||||
**WCAG AA** - 遵循WCAG 2.1 AA级标准,确保文本对比度、键盘导航、屏幕阅读器兼容性
|
|
||||||
|
|
||||||
### 3.5 Branding
|
|
||||||
|
|
||||||
- 采用现代、简洁的设计风格
|
|
||||||
- 使用系统默认字体,确保跨平台一致性
|
|
||||||
- 配色方案:浅色背景 + 深色文本,高对比度确保可读性
|
|
||||||
|
|
||||||
### 3.6 Target Device and Platforms
|
|
||||||
|
|
||||||
**Web Responsive** - 主要面向桌面浏览器,同时支持平板和手机的基本访问功能
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Technical Assumptions
|
|
||||||
|
|
||||||
### 4.1 Repository Structure
|
|
||||||
|
|
||||||
**Monorepo** - 前端和后端代码存放在同一个仓库中,便于协调开发和部署
|
|
||||||
|
|
||||||
### 4.2 Service Architecture
|
|
||||||
|
|
||||||
**Monolith** - 采用单体架构,Python后端提供API服务和静态文件服务,Vue3前端构建为静态资源
|
|
||||||
|
|
||||||
### 4.3 Testing Requirements
|
|
||||||
|
|
||||||
**Unit + Integration** - 编写单元测试覆盖核心功能,以及集成测试验证端到端流程
|
|
||||||
|
|
||||||
### 4.4 Additional Technical Assumptions and Requests
|
|
||||||
|
|
||||||
- **前端框架**: Vue 3 (Composition API) - 选择理由:现代、轻量、易学、生态系统完善
|
|
||||||
- **后端框架**: FastAPI - 选择理由:高性能、自动API文档、内置WebSocket支持、类型注解
|
|
||||||
- **实时通信**: WebSocket - 用于实现实时自动保存和状态同步
|
|
||||||
- **文件存储**: 本地文件系统 - 简单直接,无需数据库,符合项目轻量级定位
|
|
||||||
- **部署方式**: Docker容器化 - 便于在Ubuntu物理机上部署和管理
|
|
||||||
- **静态文件服务**: Nginx或Python后端直接服务 - 根据性能需求决定
|
|
||||||
- **开发语言**: TypeScript (前端) + Python 3.10+ (后端) - 类型安全、现代特性
|
|
||||||
- **构建工具**: Vite - 快速的开发服务器和构建工具
|
|
||||||
- **包管理器**: npm/pnpm (前端) + pip/poetry (后端)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Epic List
|
|
||||||
|
|
||||||
### Epic 1: 基础架构与核心编辑功能
|
|
||||||
建立项目基础架构,实现核心文本编辑和自动保存功能
|
|
||||||
|
|
||||||
### Epic 2: 文件管理与安全控制
|
|
||||||
实现文件列表页面和后端口令验证功能
|
|
||||||
|
|
||||||
### Epic 3: 边界条件处理与部署优化
|
|
||||||
完善异常处理、输入验证,配置Docker部署
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Epic 1: 基础架构与核心编辑功能
|
|
||||||
|
|
||||||
**Epic Goal**: 建立前后端项目基础架构,实现核心文本编辑功能、URL路由和自动保存机制,为应用提供基本可用的文本编辑能力。
|
|
||||||
|
|
||||||
### Story 1.1: 前端项目初始化与基础UI搭建
|
|
||||||
|
|
||||||
**作为** 开发者
|
|
||||||
**我想要** 初始化Vue3项目并创建基础文本编辑界面
|
|
||||||
**以便** 为用户提供文本编辑功能
|
|
||||||
|
|
||||||
#### Acceptance Criteria
|
|
||||||
|
|
||||||
1. 使用Vite初始化Vue3项目,配置TypeScript
|
|
||||||
2. 创建文本编辑组件,包含textarea元素占据主要界面空间
|
|
||||||
3. 实现基本的数据绑定,将textarea内容绑定到Vue响应式变量
|
|
||||||
4. 添加简单的CSS样式,确保编辑区域美观易用
|
|
||||||
5. 配置开发服务器,能够在本地运行并访问
|
|
||||||
|
|
||||||
### Story 1.2: 后端项目初始化与文件API开发
|
|
||||||
|
|
||||||
**作为** 开发者
|
|
||||||
**我想要** 搭建Python FastAPI项目并实现文件读写API
|
|
||||||
**以便** 为前端提供文件存储服务
|
|
||||||
|
|
||||||
#### Acceptance Criteria
|
|
||||||
|
|
||||||
1. 创建FastAPI项目,配置必要的依赖(fastapi, uvicorn, python-multipart)
|
|
||||||
2. 实现GET `/api/notes/{filename}` 接口:读取文件内容,文件不存在时创建空文件
|
|
||||||
3. 实现POST `/api/notes/{filename}` 接口:接收文本内容并保存到文件
|
|
||||||
4. 配置文件存储目录(如 `./data/notes/`),确保目录存在
|
|
||||||
5. 添加CORS配置,允许前端跨域访问
|
|
||||||
6. 实现基本的错误处理,返回适当的HTTP状态码和错误信息
|
|
||||||
|
|
||||||
### Story 1.3: URL路由与文件加载集成
|
|
||||||
|
|
||||||
**作为** 用户
|
|
||||||
**我想要** 通过URL访问特定文件并加载其内容
|
|
||||||
**以便** 直接编辑特定的文本文件
|
|
||||||
|
|
||||||
#### Acceptance Criteria
|
|
||||||
|
|
||||||
1. 使用Vue Router配置路由,支持 `/notes/{filename}` 格式
|
|
||||||
2. 从URL参数中提取文件名,调用后端API加载文件内容
|
|
||||||
3. 文件存在时,将内容显示在编辑区域
|
|
||||||
4. 文件不存在时,创建新文件并显示空白编辑区域
|
|
||||||
5. 在界面上方显示当前文件名
|
|
||||||
6. 处理URL编码/解码,支持中文文件名
|
|
||||||
|
|
||||||
### Story 1.4: 自动保存功能实现
|
|
||||||
|
|
||||||
**作为** 用户
|
|
||||||
**我想要** 编辑的内容能够自动保存
|
|
||||||
**以便** 无需手动操作即可保留我的更改
|
|
||||||
|
|
||||||
#### Acceptance Criteria
|
|
||||||
|
|
||||||
1. 监听文本编辑器的input事件,检测内容变化
|
|
||||||
2. 实现防抖机制,用户停止输入500ms后触发保存
|
|
||||||
3. 调用后端API将当前内容保存到文件
|
|
||||||
4. 在界面上显示保存状态指示器("已保存"、"保存中..."、"保存失败")
|
|
||||||
5. 保存成功后更新状态为"已保存"
|
|
||||||
6. 保存失败时显示错误提示,并提供重试机制
|
|
||||||
|
|
||||||
### Story 1.5: WebSocket实时通信集成
|
|
||||||
|
|
||||||
**作为** 用户
|
|
||||||
**我想要** 通过WebSocket实现实时自动保存
|
|
||||||
**以便** 获得更快的响应和更好的实时体验
|
|
||||||
|
|
||||||
#### Acceptance Criteria
|
|
||||||
|
|
||||||
1. 后端集成WebSocket支持,使用FastAPI的websocket功能
|
|
||||||
2. 前端建立WebSocket连接,保持长连接
|
|
||||||
3. 通过WebSocket发送文本内容到后端,实现实时保存
|
|
||||||
4. 后端接收到内容后立即写入文件
|
|
||||||
5. 实现连接断开后自动重连机制
|
|
||||||
6. 显示WebSocket连接状态(已连接、断开连接、重连中)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Epic 2: 文件管理与安全控制
|
|
||||||
|
|
||||||
**Epic Goal**: 实现文件列表页面,展示所有txt文件,并添加后端口令验证机制,保护文件管理功能的安全性。
|
|
||||||
|
|
||||||
### Story 2.1: 文件列表API开发
|
|
||||||
|
|
||||||
**作为** 开发者
|
|
||||||
**我想要** 实现后端API返回所有txt文件列表
|
|
||||||
**以便** 前端可以展示文件列表页面
|
|
||||||
|
|
||||||
#### Acceptance Criteria
|
|
||||||
|
|
||||||
1. 实现GET `/api/files/list` 接口,遍历文件存储目录
|
|
||||||
2. 返回所有 `.txt` 文件的列表,包含文件名和创建时间
|
|
||||||
3. 按创建时间倒序排列,最新文件显示在最前面
|
|
||||||
4. 添加分页支持,每页显示50个文件
|
|
||||||
5. 返回数据格式为JSON,包含文件总数和当前页信息
|
|
||||||
|
|
||||||
### Story 2.2: 文件列表页面UI开发
|
|
||||||
|
|
||||||
**作为** 用户
|
|
||||||
**我想要** 查看所有可编辑的文件列表
|
|
||||||
**以便** 快速找到并打开需要的文档
|
|
||||||
|
|
||||||
#### Acceptance Criteria
|
|
||||||
|
|
||||||
1. 创建文件列表页面组件 `/file-list`
|
|
||||||
2. 调用后端API获取文件列表并展示
|
|
||||||
3. 每个文件项显示文件名和创建时间(格式化显示)
|
|
||||||
4. 点击文件名跳转到对应的编辑页面(`/notes/{filename}`)
|
|
||||||
5. 实现加载状态指示器,API调用期间显示加载中
|
|
||||||
6. 处理空列表情况,显示友好提示信息
|
|
||||||
|
|
||||||
### Story 2.3: 后端口令验证机制
|
|
||||||
|
|
||||||
**作为** 用户
|
|
||||||
**我想要** 文件列表页面需要口令验证
|
|
||||||
**以便** 保护我的文件不被未经授权的访问
|
|
||||||
|
|
||||||
#### Acceptance Criteria
|
|
||||||
|
|
||||||
1. 配置文件列表访问口令(如环境变量 `FILE_LIST_PASSWORD`)
|
|
||||||
2. 修改 `/api/files/list` 接口,要求请求包含正确的口令
|
|
||||||
3. 实现POST `/api/files/verify-password` 接口,验证口令是否正确
|
|
||||||
4. 口令验证通过后,返回访问令牌(简单token)
|
|
||||||
5. 后续文件列表请求需要携带令牌才能访问
|
|
||||||
6. 令牌设置过期时间(如24小时)
|
|
||||||
|
|
||||||
### Story 2.4: 前端口令验证UI
|
|
||||||
|
|
||||||
**作为** 用户
|
|
||||||
**我想要** 在文件列表页面输入口令进行验证
|
|
||||||
**以便** 访问我的文件列表
|
|
||||||
|
|
||||||
#### Acceptance Criteria
|
|
||||||
|
|
||||||
1. 访问文件列表页面时,如果未验证,显示口令输入表单
|
|
||||||
2. 用户输入口令后,调用后端验证API
|
|
||||||
3. 验证成功后,保存令牌到localStorage
|
|
||||||
4. 使用令牌调用文件列表API,显示文件列表
|
|
||||||
5. 验证失败时显示错误消息
|
|
||||||
6. 提供"记住我"选项,保持登录状态
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Epic 3: 边界条件处理与部署优化
|
|
||||||
|
|
||||||
**Epic Goal**: 完善应用的异常处理和边界条件,实现输入验证、文件大小限制、错误处理,并配置Docker部署方案。
|
|
||||||
|
|
||||||
### Story 3.1: 输入验证与安全过滤
|
|
||||||
|
|
||||||
**作为** 开发者
|
|
||||||
**我想要** 对文件名进行严格的验证和过滤
|
|
||||||
**以便** 防止安全漏洞和非法访问
|
|
||||||
|
|
||||||
#### Acceptance Criteria
|
|
||||||
|
|
||||||
1. 实现文件名验证函数,阻止包含特殊字符的文件名(`/`, `\\`, `..`, `:`等)
|
|
||||||
2. 限制文件名长度不超过200个字符
|
|
||||||
3. 对URL中的文件名进行解码和验证
|
|
||||||
4. 验证失败时返回400 Bad Request错误
|
|
||||||
5. 记录验证失败的请求,便于安全审计
|
|
||||||
6. 前端同步实现文件名验证,提供实时反馈
|
|
||||||
|
|
||||||
### Story 3.2: 文件大小限制实现
|
|
||||||
|
|
||||||
**作为** 用户
|
|
||||||
**我想要** 系统限制文件大小不超过10万字符
|
|
||||||
**以便** 防止单个文件过大影响性能
|
|
||||||
|
|
||||||
#### Acceptance Criteria
|
|
||||||
|
|
||||||
1. 后端实现文件大小检查,读取文件时如果超过10万字符返回错误
|
|
||||||
2. 前端实现字符计数器,实时显示当前字符数
|
|
||||||
3. 当接近限制(如9.5万字符)时显示警告
|
|
||||||
4. 达到10万字符限制时,阻止用户继续输入并显示提示
|
|
||||||
5. 保存时检查内容长度,超过限制返回413 Payload Too Large错误
|
|
||||||
6. 提供友好的用户提示,说明文件大小限制
|
|
||||||
|
|
||||||
### Story 3.3: 异常处理与错误提示
|
|
||||||
|
|
||||||
**作为** 用户
|
|
||||||
**我想要** 清晰的错误提示信息
|
|
||||||
**以便** 了解发生了什么问题和如何解决
|
|
||||||
|
|
||||||
#### Acceptance Criteria
|
|
||||||
|
|
||||||
1. 定义错误码和对应的用户友好消息
|
|
||||||
2. 实现全局错误处理组件,捕获并显示错误
|
|
||||||
3. 网络错误:显示"网络连接失败,请检查网络"提示
|
|
||||||
4. 文件访问错误:显示"无法访问文件,请重试或联系管理员"
|
|
||||||
5. 权限错误:显示"访问被拒绝,请确认您有权限访问此文件"
|
|
||||||
6. 提供"重试"按钮,允许用户重新尝试失败的操作
|
|
||||||
|
|
||||||
### Story 3.4: Docker部署配置
|
|
||||||
|
|
||||||
**作为** 运维人员
|
|
||||||
**我想要** 使用Docker部署应用
|
|
||||||
**以便** 简化部署流程和环境管理
|
|
||||||
|
|
||||||
#### Acceptance Criteria
|
|
||||||
|
|
||||||
1. 创建前端Dockerfile,基于Node.js镜像构建Vue3应用
|
|
||||||
2. 创建后端Dockerfile,基于Python 3.10+镜像运行FastAPI
|
|
||||||
3. 配置docker-compose.yml,编排前后端服务
|
|
||||||
4. 配置Nginx作为反向代理,服务前端静态文件和API请求
|
|
||||||
5. 设置环境变量配置(文件存储路径、口令、端口等)
|
|
||||||
6. 编写部署文档,说明如何在Ubuntu物理机上部署
|
|
||||||
|
|
||||||
### Story 3.5: 性能优化与监控
|
|
||||||
|
|
||||||
**作为** 开发者
|
|
||||||
**我想要** 添加性能监控和优化措施
|
|
||||||
**以便** 确保应用在生产环境稳定运行
|
|
||||||
|
|
||||||
#### Acceptance Criteria
|
|
||||||
|
|
||||||
1. 后端添加请求日志,记录API调用时间、状态码
|
|
||||||
2. 实现健康检查接口 `/health`,返回服务状态
|
|
||||||
3. 前端添加性能监控,测量页面加载时间、保存响应时间
|
|
||||||
4. 配置Gunicorn/Uvicorn参数,优化并发性能
|
|
||||||
5. 实现文件缓存机制,减少重复读取
|
|
||||||
6. 添加内存使用监控,防止内存泄漏
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Next Steps
|
|
||||||
|
|
||||||
### UX Expert Prompt
|
|
||||||
|
|
||||||
基于本PRD文档,设计Vue3 + Python Notepad应用的用户界面架构,包括:
|
|
||||||
- 文本编辑页面的布局和交互设计
|
|
||||||
- 文件列表页面的视觉设计和用户体验
|
|
||||||
- 响应式设计策略,适配不同设备
|
|
||||||
- UI组件库选择和设计系统定义
|
|
||||||
|
|
||||||
### Architect Prompt
|
|
||||||
|
|
||||||
基于本PRD文档,设计Vue3 + Python Notepad应用的技术架构,包括:
|
|
||||||
- 前后端项目结构和代码组织
|
|
||||||
- API设计细节和WebSocket通信协议
|
|
||||||
- 文件存储策略和数据模型
|
|
||||||
- Docker部署架构和配置
|
|
||||||
- 安全策略和性能优化方案
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
# 构建阶段
|
|
||||||
FROM node:18-alpine as build-stage
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# 复制 package.json 和 pnpm-lock.yaml
|
|
||||||
COPY package*.json pnpm-lock.yaml* ./
|
|
||||||
|
|
||||||
# 安装 pnpm
|
|
||||||
RUN npm install -g pnpm
|
|
||||||
|
|
||||||
# 安装依赖
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
# 复制源代码
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# 构建应用
|
|
||||||
RUN pnpm run build
|
|
||||||
|
|
||||||
# 生产阶段
|
|
||||||
FROM nginx:alpine as production-stage
|
|
||||||
|
|
||||||
# 复制构建产物到 nginx
|
|
||||||
COPY --from=build-stage /app/dist /usr/share/nginx/html
|
|
||||||
|
|
||||||
# 复制 nginx 配置
|
|
||||||
COPY nginx.conf /etc/nginx/nginx.conf
|
|
||||||
|
|
||||||
# 暴露端口
|
|
||||||
EXPOSE 80
|
|
||||||
|
|
||||||
# 启动 nginx
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
BIN
frontend/favicon.ico
Normal file
BIN
frontend/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<link rel="icon" href="/favicon.ico">
|
<link rel="icon" href="/favicon.ico">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Vue3 + Python Notepad</title>
|
<title>%VITE_APP_TITLE%</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@ -1,80 +0,0 @@
|
|||||||
events {
|
|
||||||
worker_connections 1024;
|
|
||||||
}
|
|
||||||
|
|
||||||
http {
|
|
||||||
include /etc/nginx/mime.types;
|
|
||||||
default_type application/octet-stream;
|
|
||||||
|
|
||||||
# 日志格式
|
|
||||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
|
||||||
'$status $body_bytes_sent "$http_referer" '
|
|
||||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
|
||||||
|
|
||||||
access_log /var/log/nginx/access.log main;
|
|
||||||
error_log /var/log/nginx/error.log warn;
|
|
||||||
|
|
||||||
# 基本设置
|
|
||||||
sendfile on;
|
|
||||||
tcp_nopush on;
|
|
||||||
tcp_nodelay on;
|
|
||||||
keepalive_timeout 65;
|
|
||||||
types_hash_max_size 2048;
|
|
||||||
|
|
||||||
# Gzip 压缩
|
|
||||||
gzip on;
|
|
||||||
gzip_vary on;
|
|
||||||
gzip_min_length 1024;
|
|
||||||
gzip_types
|
|
||||||
text/plain
|
|
||||||
text/css
|
|
||||||
text/xml
|
|
||||||
text/javascript
|
|
||||||
application/javascript
|
|
||||||
application/xml+rss
|
|
||||||
application/json;
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name localhost;
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
# 处理前端路由
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
# 代理 API 请求到后端
|
|
||||||
location /api {
|
|
||||||
proxy_pass http://backend:8000;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection 'upgrade';
|
|
||||||
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;
|
|
||||||
proxy_cache_bypass $http_upgrade;
|
|
||||||
}
|
|
||||||
|
|
||||||
# 代理 WebSocket 请求到后端
|
|
||||||
location /ws {
|
|
||||||
proxy_pass http://backend:8000;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
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;
|
|
||||||
proxy_read_timeout 86400;
|
|
||||||
}
|
|
||||||
|
|
||||||
# 静态资源缓存
|
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
|
||||||
expires 1y;
|
|
||||||
add_header Cache-Control "public, immutable";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -3,7 +3,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"build": "vue-tsc && vite build",
|
"build": "vue-tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
baseURL: import.meta.env.VITE_API_BASE_URL || 'https://api.yuany3721.site',
|
||||||
timeout: 10000
|
timeout: 10000
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -21,7 +21,10 @@ api.interceptors.response.use(
|
|||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
// 令牌过期,清除本地存储
|
// 令牌过期,清除本地存储
|
||||||
localStorage.removeItem('authToken')
|
localStorage.removeItem('authToken')
|
||||||
window.location.reload()
|
// 只在非密码验证页面刷新
|
||||||
|
if (!error.config.url?.includes('verify-password')) {
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
@ -39,6 +42,7 @@ export interface FileListItem {
|
|||||||
filename: string
|
filename: string
|
||||||
created_at: string
|
created_at: string
|
||||||
size: number
|
size: number
|
||||||
|
preview?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthToken {
|
export interface AuthToken {
|
||||||
@ -54,22 +58,22 @@ export interface FileListResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const notesApi = {
|
export const notesApi = {
|
||||||
getFile: (filename: string) => api.get<TextFile>(`/notes/${filename}`),
|
getFile: (filename: string) => api.get<TextFile>(`/notepad/notes/${filename}`),
|
||||||
saveFile: (filename: string, content: string) =>
|
saveFile: (filename: string, content: string) =>
|
||||||
api.post<TextFile>(`/notes/${filename}`, { content })
|
api.post<TextFile>(`/notepad/notes/${filename}`, { content })
|
||||||
}
|
}
|
||||||
|
|
||||||
export const filesApi = {
|
export const filesApi = {
|
||||||
getList: (page = 1, limit = 50) =>
|
getList: (page = 1, limit = 50) =>
|
||||||
api.get<FileListResponse>(
|
api.get<FileListResponse>(
|
||||||
'/files/list', { params: { page, limit } }
|
'/notepad/files/list', { params: { page, limit } }
|
||||||
),
|
),
|
||||||
verifyPassword: (password: string) =>
|
verifyPassword: (password: string) =>
|
||||||
api.post<AuthToken>('/files/verify-password', { password }),
|
api.post<AuthToken>('/notepad/files/verify-password', { password }),
|
||||||
deleteFile: (filename: string) =>
|
deleteFile: (filename: string) =>
|
||||||
api.delete(`/files/${filename}`),
|
api.post(`/notepad/files/${filename}/delete`),
|
||||||
renameFile: (filename: string, newFilename: string) =>
|
renameFile: (filename: string, newFilename: string) =>
|
||||||
api.put(`/files/${filename}/rename?new_filename=${encodeURIComponent(newFilename)}`)
|
api.post(`/notepad/files/${filename}/rename?new_filename=${encodeURIComponent(newFilename)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default api
|
export default api
|
||||||
@ -6,58 +6,133 @@ export function useWebSocket() {
|
|||||||
const reconnectAttempts = ref(0)
|
const reconnectAttempts = ref(0)
|
||||||
const maxReconnectAttempts = 5
|
const maxReconnectAttempts = 5
|
||||||
const editorStore = useEditorStore()
|
const editorStore = useEditorStore()
|
||||||
|
let reconnectTimeout: number | null = null
|
||||||
|
let currentFilename = ''
|
||||||
|
let isConnecting = false
|
||||||
|
let usePolling = false
|
||||||
|
let pollInterval: number | null = null
|
||||||
|
|
||||||
const connect = (filename: string) => {
|
const connect = (filename: string) => {
|
||||||
if (ws) {
|
if (isConnecting && currentFilename === filename) return
|
||||||
ws.close()
|
|
||||||
|
if (reconnectTimeout) {
|
||||||
|
clearTimeout(reconnectTimeout)
|
||||||
|
reconnectTimeout = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用相对路径,通过 Vite 代理
|
if (ws) {
|
||||||
const wsUrl = `/ws/${filename}`
|
const oldWs = ws
|
||||||
if (import.meta.env.DEV) console.log(`Connecting to WebSocket at: ${wsUrl}`)
|
ws = null
|
||||||
|
oldWs.onclose = null
|
||||||
|
oldWs.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
currentFilename = filename
|
||||||
|
isConnecting = false
|
||||||
|
usePolling = false
|
||||||
|
|
||||||
|
const apiBaseURL = import.meta.env.VITE_API_BASE_URL || 'https://api.yuany3721.site'
|
||||||
|
let wsUrl: string
|
||||||
|
|
||||||
|
if (apiBaseURL.startsWith('/')) {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
const host = window.location.host
|
||||||
|
wsUrl = `${protocol}//${host}/notepad/ws/${filename}`
|
||||||
|
} else {
|
||||||
|
const wsHost = import.meta.env.VITE_WS_HOST || apiBaseURL.replace(/^https?:\/\//, '')
|
||||||
|
const protocol = apiBaseURL.startsWith('https:') ? 'wss:' : 'ws:'
|
||||||
|
wsUrl = `${protocol}//${wsHost}/notepad/ws/${filename}`
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
isConnecting = true
|
||||||
ws = new WebSocket(wsUrl)
|
ws = new WebSocket(wsUrl)
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
reconnectAttempts.value = 0
|
reconnectAttempts.value = 0
|
||||||
// 只在开发环境输出
|
isConnecting = false
|
||||||
if (import.meta.env.DEV) console.log('WebSocket connected')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
// 只在开发环境输出
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log('📨 WebSocket message received:', event.data)
|
|
||||||
}
|
|
||||||
const message = JSON.parse(event.data)
|
const message = JSON.parse(event.data)
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log('📨 Parsed message:', message)
|
|
||||||
}
|
|
||||||
handleMessage(message)
|
handleMessage(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onclose = (event) => {
|
ws.onclose = (event) => {
|
||||||
// 只在开发环境输出
|
isConnecting = false
|
||||||
if (import.meta.env.DEV) console.log('WebSocket closed:', event.code, event.reason)
|
|
||||||
if (reconnectAttempts.value < maxReconnectAttempts) {
|
if (currentFilename === filename && reconnectAttempts.value < maxReconnectAttempts) {
|
||||||
setTimeout(() => {
|
reconnectTimeout = window.setTimeout(() => {
|
||||||
reconnectAttempts.value++
|
reconnectAttempts.value++
|
||||||
if (import.meta.env.DEV) console.log(`Reconnecting... attempt ${reconnectAttempts.value}`)
|
|
||||||
connect(filename)
|
connect(filename)
|
||||||
}, 1000 * reconnectAttempts.value)
|
}, 1000 * reconnectAttempts.value)
|
||||||
} else {
|
|
||||||
console.error('Max reconnection attempts reached')
|
|
||||||
editorStore.setSaveStatus('error')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onerror = (error) => {
|
ws.onerror = (error) => {
|
||||||
console.error('WebSocket error:', error)
|
isConnecting = false
|
||||||
editorStore.setSaveStatus('error')
|
editorStore.setSaveStatus('error')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (import.meta.env.DEV) console.error('Failed to create WebSocket:', error)
|
isConnecting = false
|
||||||
|
editorStore.setSaveStatus('error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startPolling = (filename: string) => {
|
||||||
|
usePolling = true
|
||||||
|
reconnectAttempts.value = 0
|
||||||
|
|
||||||
|
pollInterval = window.setInterval(() => {
|
||||||
|
checkFileStatus(filename)
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopPolling = () => {
|
||||||
|
if (pollInterval) {
|
||||||
|
clearInterval(pollInterval)
|
||||||
|
pollInterval = null
|
||||||
|
}
|
||||||
|
usePolling = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkFileStatus = async (filename: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/notepad/notes/${filename}`)
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.content !== editorStore.content) {
|
||||||
|
editorStore.setContent(data.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Polling error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveViaHttp = async (filename: string, content: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/notepad/notes/${filename}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ content })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const mockResponse = {
|
||||||
|
type: 'save_response',
|
||||||
|
status: 'success',
|
||||||
|
message: '保存成功',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
handleMessage(mockResponse)
|
||||||
|
} else {
|
||||||
|
throw new Error('HTTP save failed')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('HTTP save error:', error)
|
||||||
editorStore.setSaveStatus('error')
|
editorStore.setSaveStatus('error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -69,31 +144,43 @@ export function useWebSocket() {
|
|||||||
content,
|
content,
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
}))
|
}))
|
||||||
|
} else if (usePolling) {
|
||||||
|
saveViaHttp(filename, content)
|
||||||
|
} else {
|
||||||
|
startPolling(filename)
|
||||||
|
saveViaHttp(filename, content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const disconnect = () => {
|
const disconnect = () => {
|
||||||
|
if (reconnectTimeout) {
|
||||||
|
clearTimeout(reconnectTimeout)
|
||||||
|
reconnectTimeout = null
|
||||||
|
}
|
||||||
|
|
||||||
if (ws) {
|
if (ws) {
|
||||||
|
ws.onclose = null
|
||||||
ws.close()
|
ws.close()
|
||||||
ws = null
|
ws = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stopPolling()
|
||||||
|
|
||||||
|
isConnecting = false
|
||||||
|
currentFilename = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMessage = (message: any) => {
|
const handleMessage = (message: any) => {
|
||||||
if (import.meta.env.DEV) console.log('🔄 Handling WebSocket message:', message)
|
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case 'save_response':
|
case 'save_response':
|
||||||
if (import.meta.env.DEV) console.log('✅ Save response received:', message.status)
|
editorStore.setSaveStatus(
|
||||||
editorStore.setSaveStatus(
|
message.status === 'success' ? 'saved' : 'error'
|
||||||
message.status === 'success' ? 'saved' : 'error'
|
)
|
||||||
)
|
break
|
||||||
break
|
case 'error':
|
||||||
case 'error':
|
console.error('WebSocket error:', message.message)
|
||||||
console.error('❌ WebSocket error:', message.message)
|
editorStore.setSaveStatus('error')
|
||||||
editorStore.setSaveStatus('error')
|
break
|
||||||
break
|
|
||||||
default:
|
|
||||||
if (import.meta.env.DEV) console.log('❓ Unknown message type:', message.type)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
31
frontend/src/utils/eventBus.ts
Normal file
31
frontend/src/utils/eventBus.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
|
||||||
|
type EventHandler = (...args: any[]) => void
|
||||||
|
|
||||||
|
class EventBus {
|
||||||
|
private events: Record<string, EventHandler[]> = {}
|
||||||
|
|
||||||
|
on(event: string, handler: EventHandler) {
|
||||||
|
if (!this.events[event]) {
|
||||||
|
this.events[event] = []
|
||||||
|
}
|
||||||
|
this.events[event].push(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
off(event: string, handler: EventHandler) {
|
||||||
|
if (!this.events[event]) return
|
||||||
|
const index = this.events[event].indexOf(handler)
|
||||||
|
if (index > -1) {
|
||||||
|
this.events[event].splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event: string, ...args: any[]) {
|
||||||
|
if (!this.events[event]) return
|
||||||
|
this.events[event].forEach(handler => {
|
||||||
|
handler(...args)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emitter = new EventBus()
|
||||||
@ -93,6 +93,7 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { useEditorStore } from '@/stores/editor'
|
import { useEditorStore } from '@/stores/editor'
|
||||||
import { notesApi, filesApi } from '@/services/api'
|
import { notesApi, filesApi } from '@/services/api'
|
||||||
import { useWebSocket } from '@/services/websocket'
|
import { useWebSocket } from '@/services/websocket'
|
||||||
|
import { emitter } from '@/utils/eventBus'
|
||||||
import StatusIndicator from '@/components/common/StatusIndicator.vue'
|
import StatusIndicator from '@/components/common/StatusIndicator.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@ -149,12 +150,8 @@ const handleInput = () => {
|
|||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
if (testingMode.value) console.log('✅ Using WebSocket to save')
|
if (testingMode.value) console.log('✅ Using WebSocket to save')
|
||||||
try {
|
try {
|
||||||
// 确保文件名包含 .txt 后缀
|
if (testingMode.value) console.log('Sending to WebSocket with filename:', editorStore.currentFile)
|
||||||
const filename = editorStore.currentFile.toLowerCase().endsWith('.txt')
|
sendSave(editorStore.currentFile, editorStore.content)
|
||||||
? editorStore.currentFile
|
|
||||||
: `${editorStore.currentFile}.txt`
|
|
||||||
if (testingMode.value) console.log('Sending to WebSocket with filename:', filename)
|
|
||||||
sendSave(filename, editorStore.content)
|
|
||||||
if (testingMode.value) console.log('WebSocket message sent, waiting for response...')
|
if (testingMode.value) console.log('WebSocket message sent, waiting for response...')
|
||||||
// 设置超时,如果 WebSocket 5秒内没有响应,回退到 HTTP
|
// 设置超时,如果 WebSocket 5秒内没有响应,回退到 HTTP
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@ -197,7 +194,7 @@ const cancelRename = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deleteFile = async () => {
|
const deleteFile = async () => {
|
||||||
if (!confirm(`确定要删除文件 "${displayName}" 吗?此操作不可恢复。`)) {
|
if (!confirm(`确定要删除文件 "${displayName.value}" 吗?此操作不可恢复。`)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -217,14 +214,9 @@ const deleteFile = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const testSave = async () => {
|
const testSave = async () => {
|
||||||
// 确保文件名包含 .txt 后缀
|
|
||||||
const filename = editorStore.currentFile.toLowerCase().endsWith('.txt')
|
|
||||||
? editorStore.currentFile
|
|
||||||
: `${editorStore.currentFile}.txt`
|
|
||||||
|
|
||||||
if (testingMode.value) {
|
if (testingMode.value) {
|
||||||
console.log('=== Manual Test Save ===')
|
console.log('=== Manual Test Save ===')
|
||||||
console.log('Current file:', filename)
|
console.log('Current file:', editorStore.currentFile)
|
||||||
console.log('Content length:', editorStore.content.length)
|
console.log('Content length:', editorStore.content.length)
|
||||||
console.log('Content preview:', editorStore.content.substring(0, 100))
|
console.log('Content preview:', editorStore.content.substring(0, 100))
|
||||||
}
|
}
|
||||||
@ -232,7 +224,7 @@ const testSave = async () => {
|
|||||||
editorStore.setSaveStatus('saving')
|
editorStore.setSaveStatus('saving')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await notesApi.saveFile(filename, editorStore.content)
|
const response = await notesApi.saveFile(editorStore.currentFile, editorStore.content)
|
||||||
if (testingMode.value) console.log('Test save response:', response)
|
if (testingMode.value) console.log('Test save response:', response)
|
||||||
editorStore.setSaveStatus('saved')
|
editorStore.setSaveStatus('saved')
|
||||||
if (testingMode.value) console.log('✅ Test save successful!')
|
if (testingMode.value) console.log('✅ Test save successful!')
|
||||||
@ -275,47 +267,49 @@ const confirmRename = async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const oldFilename = editorStore.currentFile
|
const oldFilename = editorStore.currentFile
|
||||||
const newFullFilename = newFilename.value.toLowerCase().endsWith('.txt')
|
|
||||||
? newFilename.value
|
|
||||||
: `${newFilename.value}.txt`
|
|
||||||
|
|
||||||
await filesApi.renameFile(oldFilename, newFullFilename)
|
// 先断开当前WebSocket连接
|
||||||
|
disconnect()
|
||||||
|
|
||||||
// 更新当前文件名
|
await filesApi.renameFile(oldFilename, newFilename.value)
|
||||||
editorStore.setCurrentFile(newFullFilename)
|
|
||||||
|
// 重命名成功,更新当前文件名
|
||||||
|
editorStore.setCurrentFile(newFilename.value)
|
||||||
|
|
||||||
// 更新URL
|
// 更新URL
|
||||||
router.replace(`/notes/${getDisplayName(newFullFilename)}`)
|
router.replace(`/notes/${newFilename.value}`)
|
||||||
|
|
||||||
|
// 连接到新的WebSocket
|
||||||
|
connect(newFilename.value)
|
||||||
|
|
||||||
cancelRename()
|
cancelRename()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Rename error:', err)
|
console.error('Rename error:', err)
|
||||||
renameError.value = err.response?.data?.detail || '重命名失败'
|
renameError.value = err.response?.data?.detail || '重命名失败'
|
||||||
|
|
||||||
|
// 重命名失败,重连到旧的WebSocket
|
||||||
|
const oldFilename = editorStore.currentFile
|
||||||
|
connect(oldFilename)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveViaHttp = async () => {
|
const saveViaHttp = async () => {
|
||||||
try {
|
try {
|
||||||
// 确保文件名包含 .txt 后缀
|
|
||||||
const filename = editorStore.currentFile.toLowerCase().endsWith('.txt')
|
|
||||||
? editorStore.currentFile
|
|
||||||
: `${editorStore.currentFile}.txt`
|
|
||||||
|
|
||||||
// 检查内容是否为空
|
// 检查内容是否为空
|
||||||
if (!editorStore.content || editorStore.content.trim() === '') {
|
if (!editorStore.content || editorStore.content.trim() === '') {
|
||||||
if (testingMode.value) console.log('Content is empty, deleting file:', filename)
|
if (testingMode.value) console.log('Content is empty, deleting file:', editorStore.currentFile)
|
||||||
await filesApi.deleteFile(filename)
|
await filesApi.deleteFile(editorStore.currentFile)
|
||||||
editorStore.setSaveStatus('saved')
|
editorStore.setSaveStatus('saved')
|
||||||
if (testingMode.value) console.log('Empty file deleted successfully')
|
if (testingMode.value) console.log('Empty file deleted successfully')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (testingMode.value) {
|
if (testingMode.value) {
|
||||||
console.log('Attempting HTTP save for:', filename)
|
console.log('Attempting HTTP save for:', editorStore.currentFile)
|
||||||
console.log('Request payload:', { content: editorStore.content })
|
console.log('Request payload:', { content: editorStore.content })
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await notesApi.saveFile(filename, editorStore.content)
|
const response = await notesApi.saveFile(editorStore.currentFile, editorStore.content)
|
||||||
if (testingMode.value) console.log('HTTP save response:', response)
|
if (testingMode.value) console.log('HTTP save response:', response)
|
||||||
|
|
||||||
editorStore.setSaveStatus('saved')
|
editorStore.setSaveStatus('saved')
|
||||||
@ -343,14 +337,11 @@ const saveViaHttp = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadFile = async (filename: string) => {
|
const loadFile = async (filename: string) => {
|
||||||
// 确保文件名包含 .txt 后缀
|
|
||||||
const fullFilename = filename.toLowerCase().endsWith('.txt') ? filename : `${filename}.txt`
|
|
||||||
|
|
||||||
editorStore.setLoading(true)
|
editorStore.setLoading(true)
|
||||||
editorStore.setCurrentFile(fullFilename)
|
editorStore.setCurrentFile(filename)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await notesApi.getFile(fullFilename)
|
const response = await notesApi.getFile(filename)
|
||||||
editorStore.setContent(response.data.content)
|
editorStore.setContent(response.data.content)
|
||||||
editorStore.setSaveStatus('saved')
|
editorStore.setSaveStatus('saved')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -366,21 +357,30 @@ onMounted(() => {
|
|||||||
editorElement.value?.focus()
|
editorElement.value?.focus()
|
||||||
const filename = route.params.filename as string
|
const filename = route.params.filename as string
|
||||||
loadFile(filename)
|
loadFile(filename)
|
||||||
// 确保WebSocket连接使用完整的文件名(包含.txt)
|
connect(filename)
|
||||||
const fullFilename = filename.toLowerCase().endsWith('.txt') ? filename : `${filename}.txt`
|
|
||||||
connect(fullFilename)
|
// 监听来自FileListView的事件
|
||||||
|
emitter.on('disconnect-websocket', () => {
|
||||||
|
disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
emitter.on('connect-websocket', (filename: string) => {
|
||||||
|
connect(filename)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
disconnect()
|
disconnect()
|
||||||
|
|
||||||
|
// 清理事件监听器
|
||||||
|
emitter.off('disconnect-websocket', () => {})
|
||||||
|
emitter.off('connect-websocket', () => {})
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => route.params.filename, (newFilename) => {
|
watch(() => route.params.filename, (newFilename) => {
|
||||||
const filename = newFilename as string
|
const filename = newFilename as string
|
||||||
loadFile(filename)
|
loadFile(filename)
|
||||||
// 确保WebSocket连接使用完整的文件名(包含.txt)
|
connect(filename)
|
||||||
const fullFilename = filename.toLowerCase().endsWith('.txt') ? filename : `${filename}.txt`
|
|
||||||
connect(fullFilename)
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -53,7 +53,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<RouterLink to="/notes/new-file" class="btn btn-primary">
|
<RouterLink to="/" class="btn btn-primary">
|
||||||
新建文件
|
新建文件
|
||||||
</RouterLink> <button @click="logout" class="btn btn-secondary">
|
</RouterLink> <button @click="logout" class="btn btn-secondary">
|
||||||
退出
|
退出
|
||||||
@ -79,7 +79,7 @@
|
|||||||
<p v-else>
|
<p v-else>
|
||||||
还没有文件,创建您的第一个文件吧!
|
还没有文件,创建您的第一个文件吧!
|
||||||
</p>
|
</p>
|
||||||
<RouterLink to="/notes/new-file" class="btn btn-primary">
|
<RouterLink to="/" class="btn btn-primary">
|
||||||
创建文件
|
创建文件
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
@ -168,6 +168,7 @@
|
|||||||
import { ref, onMounted, nextTick, watch } from 'vue'
|
import { ref, onMounted, nextTick, watch } from 'vue'
|
||||||
import { RouterLink, useRouter } from 'vue-router'
|
import { RouterLink, useRouter } from 'vue-router'
|
||||||
import { filesApi, type FileListItem } from '@/services/api'
|
import { filesApi, type FileListItem } from '@/services/api'
|
||||||
|
import { emitter } from '@/utils/eventBus'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const files = ref<FileListItem[]>([])
|
const files = ref<FileListItem[]>([])
|
||||||
@ -192,7 +193,7 @@ const fetchFiles = async () => {
|
|||||||
const response = await filesApi.getList()
|
const response = await filesApi.getList()
|
||||||
files.value = response.data.files
|
files.value = response.data.files
|
||||||
filterFiles()
|
filterFiles()
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
if (err.response?.status === 401) {
|
if (err.response?.status === 401) {
|
||||||
// 需要认证
|
// 需要认证
|
||||||
isAuthenticated.value = false
|
isAuthenticated.value = false
|
||||||
@ -234,7 +235,7 @@ const verifyPassword = async () => {
|
|||||||
localStorage.setItem('authToken', response.data.token)
|
localStorage.setItem('authToken', response.data.token)
|
||||||
isAuthenticated.value = true
|
isAuthenticated.value = true
|
||||||
await fetchFiles()
|
await fetchFiles()
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
error.value = err.response?.data?.detail || '口令验证失败'
|
error.value = err.response?.data?.detail || '口令验证失败'
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@ -260,7 +261,7 @@ const deleteFile = async (filename: string) => {
|
|||||||
// 从列表中移除文件
|
// 从列表中移除文件
|
||||||
files.value = files.value.filter(f => f.filename !== filename)
|
files.value = files.value.filter(f => f.filename !== filename)
|
||||||
// filterFiles 会自动更新 filteredFiles
|
// filterFiles 会自动更新 filteredFiles
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
error.value = err.response?.data?.detail || '删除文件失败'
|
error.value = err.response?.data?.detail || '删除文件失败'
|
||||||
} finally {
|
} finally {
|
||||||
deleting.value = null
|
deleting.value = null
|
||||||
@ -291,26 +292,51 @@ const confirmRename = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保文件名以 .txt 结尾
|
|
||||||
const finalFilename = newFilename.value.toLowerCase().endsWith('.txt')
|
|
||||||
? newFilename.value
|
|
||||||
: `${newFilename.value}.txt`
|
|
||||||
|
|
||||||
renaming.value = true
|
renaming.value = true
|
||||||
renameError.value = null
|
renameError.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await filesApi.renameFile(renamingFile.value, finalFilename)
|
// 确保传递给后端的文件名不包含.txt后缀
|
||||||
|
const oldFilename = getDisplayName(renamingFile.value)
|
||||||
|
|
||||||
|
// 如果当前正在编辑这个文件,先断开WebSocket连接
|
||||||
|
const currentRoute = router.currentRoute.value
|
||||||
|
const currentFilename = currentRoute.params.filename as string
|
||||||
|
const isEditingCurrentFile = currentFilename === oldFilename
|
||||||
|
|
||||||
|
if (isEditingCurrentFile) {
|
||||||
|
// 通过事件总线通知编辑器断开WebSocket
|
||||||
|
emitter.emit('disconnect-websocket')
|
||||||
|
}
|
||||||
|
|
||||||
|
await filesApi.renameFile(oldFilename, newFilename.value)
|
||||||
|
|
||||||
// 更新列表中的文件名
|
// 更新列表中的文件名
|
||||||
const fileIndex = files.value.findIndex(f => f.filename === renamingFile.value)
|
const fileIndex = files.value.findIndex(f => f.filename === renamingFile.value)
|
||||||
if (fileIndex !== -1) {
|
if (fileIndex !== -1) {
|
||||||
files.value[fileIndex].filename = finalFilename
|
files.value[fileIndex].filename = `${newFilename.value}.txt`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果当前正在编辑这个文件,导航到新的文件名
|
||||||
|
if (isEditingCurrentFile) {
|
||||||
|
router.push(`/editor/${newFilename.value}`)
|
||||||
|
// 通过事件总线通知编辑器连接新的WebSocket
|
||||||
|
emitter.emit('connect-websocket', newFilename.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelRename()
|
cancelRename()
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
renameError.value = err.response?.data?.detail || '重命名失败'
|
renameError.value = err.response?.data?.detail || '重命名失败'
|
||||||
|
|
||||||
|
// 重命名失败,如果当前正在编辑这个文件,重连WebSocket
|
||||||
|
const currentRoute = router.currentRoute.value
|
||||||
|
const currentFilename = currentRoute.params.filename as string
|
||||||
|
const oldFilename = getDisplayName(renamingFile.value)
|
||||||
|
|
||||||
|
if (currentFilename === oldFilename) {
|
||||||
|
// 通过事件总线通知编辑器重连WebSocket
|
||||||
|
emitter.emit('connect-websocket', oldFilename)
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
renaming.value = false
|
renaming.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
1
frontend/tsconfig.tsbuildinfo
Normal file
1
frontend/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
@ -10,19 +10,10 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
host: '0.0.0.0',
|
||||||
proxy: {
|
port: 5173
|
||||||
'/api': {
|
|
||||||
target: 'http://localhost:8000',
|
|
||||||
changeOrigin: true
|
|
||||||
},
|
|
||||||
'/ws': {
|
|
||||||
target: 'ws://localhost:8000',
|
|
||||||
ws: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: '../backend/static'
|
outDir: 'dist'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user