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_here
|
||||
FILE_LIST_PASSWORD=your_secure_password_change_this
|
||||
JWT_SECRET_KEY=your_jwt_secret_key_change_this
|
||||
|
||||
# 端口配置
|
||||
# 容器对外暴露的端口(修改后需要更新 docker-compose.yml 中的端口映射)
|
||||
EXTERNAL_PORT=80
|
||||
# 容器对外暴露的端口
|
||||
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实时通信
|
||||
- ✅ 响应式设计
|
||||
- ✅ 文件大小限制(10万字符)
|
||||
- ✅ 文件大小限制(100KB)
|
||||
- ✅ 文件名验证与安全过滤
|
||||
- ✅ 统一容器部署
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **前端**: Vue 3, TypeScript, Vite, Pinia, Vue Router
|
||||
- **后端**: Python, FastAPI, WebSocket
|
||||
- **后端**: Python 3.12, FastAPI, WebSocket
|
||||
- **部署**: Docker, Nginx, Supervisor
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 前置要求
|
||||
|
||||
- Node.js 18+
|
||||
- Python 3.10+
|
||||
- pnpm 8+
|
||||
|
||||
### 安装步骤
|
||||
### Docker 部署(推荐)
|
||||
|
||||
1. **克隆项目**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd vue-python-notepad
|
||||
cd notepad
|
||||
```
|
||||
|
||||
2. **安装依赖**
|
||||
2. **配置环境变量**
|
||||
```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 ..
|
||||
|
||||
# 设置Python虚拟环境并安装后端依赖
|
||||
# 后端
|
||||
cd backend
|
||||
python -m venv venv
|
||||
# Windows
|
||||
venv\Scripts\activate
|
||||
# macOS/Linux
|
||||
source venv/bin/activate
|
||||
source venv/bin/activate # Linux/Mac
|
||||
# 或 venv\Scripts\activate # Windows
|
||||
pip install -r requirements.txt
|
||||
cd ..
|
||||
```
|
||||
|
||||
3. **配置环境变量**
|
||||
2. **启动服务**
|
||||
```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. **创建/编辑文件**
|
||||
- 访问 `http://localhost:5173/notes/filename.txt`
|
||||
- 如果文件不存在,会自动创建
|
||||
- 输入内容会自动保存(500ms延迟)
|
||||
- 访问 `http://localhost:7024/notes/filename.txt`
|
||||
- 文件不存在时自动创建
|
||||
- 内容自动保存
|
||||
|
||||
2. **文件列表**
|
||||
- 访问 `http://localhost:5173/file-list`
|
||||
- 输入口令(默认:your_secure_password)
|
||||
- 查看和管理所有文件
|
||||
2. **文件列表管理**
|
||||
- 访问 `http://localhost:7024/file-list`
|
||||
- 输入口令验证
|
||||
- 查看、重命名、删除文件
|
||||
|
||||
3. **URL分享**
|
||||
- 直接分享文件URL给他人
|
||||
- 例如:`http://localhost:5173/notes/shared-note.txt`
|
||||
- 直接分享文件URL
|
||||
- 例如:`http://localhost:7024/notes/shared-note.txt`
|
||||
|
||||
### 环境变量配置
|
||||
|
||||
```bash
|
||||
# 前端环境变量
|
||||
VITE_API_BASE_URL=http://localhost:8000/api
|
||||
VITE_WS_BASE_URL=ws://localhost:8000/ws
|
||||
# 应用配置
|
||||
PORT=7024 # 容器对外端口
|
||||
FILE_LIST_PASSWORD=your_password # 文件列表访问密码
|
||||
|
||||
# 后端环境变量
|
||||
NOTES_DIR=./data/notes # 文件存储目录
|
||||
FILE_LIST_PASSWORD=your_secure_password # 文件列表访问口令
|
||||
JWT_SECRET_KEY=your_jwt_secret_key # JWT密钥
|
||||
JWT_EXPIRE_HOURS=24 # 令牌过期时间
|
||||
# 文件存储
|
||||
NOTES_DIR=./data # 容器外映射存储路径
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
vue-python-notepad/
|
||||
├── frontend/ # Vue3前端应用
|
||||
notepad/
|
||||
├── frontend/ # Vue3前端
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # UI组件
|
||||
│ │ ├── views/ # 页面组件
|
||||
│ │ ├── stores/ # 状态管理
|
||||
│ │ ├── services/ # API服务
|
||||
│ │ └── router/ # 路由配置
|
||||
│ ├── package.json
|
||||
│ └── vite.config.ts
|
||||
├── backend/ # FastAPI后端应用
|
||||
│ │ └── stores/ # 状态管理
|
||||
│ └── package.json
|
||||
├── backend/ # FastAPI后端
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # API路由
|
||||
│ │ ├── services/ # 业务逻辑
|
||||
│ │ ├── models/ # 数据模型
|
||||
│ │ └── utils/ # 工具函数
|
||||
│ ├── main.py
|
||||
│ └── requirements.txt
|
||||
├── docs/ # 项目文档
|
||||
├── package.json # 根配置文件
|
||||
├── docker/ # Docker配置
|
||||
│ ├── nginx.conf
|
||||
│ ├── default.conf
|
||||
│ └── supervisord.conf
|
||||
├── docker-compose.yml # 容器编排
|
||||
├── Dockerfile # 统一构建文件
|
||||
└── 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
|
||||
### 网络流程
|
||||
```
|
||||
|
||||
### 构建部署
|
||||
|
||||
```bash
|
||||
# 构建前端
|
||||
pnpm build
|
||||
|
||||
# 生产环境启动
|
||||
cd backend
|
||||
venv\Scripts\activate # Windows
|
||||
python main.py
|
||||
用户请求 → Nginx(80) → 后端(7025)
|
||||
↓
|
||||
前端静态文件
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
1. **端口冲突**
|
||||
- 确保8000和5173端口未被占用
|
||||
- 或修改配置文件中的端口设置
|
||||
1. **容器启动失败**
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
2. **Python依赖问题**
|
||||
- 确保使用Python 3.10+
|
||||
- 激活虚拟环境后安装依赖
|
||||
2. **文件不持久化**
|
||||
- 检查 `.env` 中的 `NOTES_DIR` 配置
|
||||
- 确认数据目录权限
|
||||
|
||||
3. **前端代理错误**
|
||||
- 检查 `frontend/vite.config.ts` 中的代理配置
|
||||
- 确保后端服务器正在运行
|
||||
3. **WebSocket连接失败**
|
||||
- 检查 Nginx 配置
|
||||
- 确认防火墙设置
|
||||
|
||||
4. **WebSocket连接失败**
|
||||
- 检查防火墙设置
|
||||
- 确认WebSocket URL正确
|
||||
|
||||
## 贡献指南
|
||||
|
||||
1. Fork 项目
|
||||
2. 创建功能分支
|
||||
3. 提交更改
|
||||
4. 推送到分支
|
||||
5. 创建 Pull Request
|
||||
4. **端口冲突**
|
||||
- 修改 `.env` 中的 `PORT` 变量
|
||||
|
||||
## 许可证
|
||||
|
||||
|
||||
@ -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.utils.config import get_settings
|
||||
|
||||
app = FastAPI(
|
||||
title="Vue3 + Python Notepad API",
|
||||
description="简单的文本编辑器API,支持文件读写和列表功能",
|
||||
version="1.0.0"
|
||||
)
|
||||
settings = get_settings()
|
||||
app = FastAPI(title=f"{settings.app_name} API", description="NOTEPAD API", version="1.0.0")
|
||||
|
||||
# 配置CORS
|
||||
app.add_middleware(
|
||||
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_methods=["*"],
|
||||
allow_methods=["GET", "POST"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 注册路由
|
||||
app.include_router(notes.router, prefix="/api", tags=["notes"])
|
||||
app.include_router(files.router, prefix="/api", tags=["files"])
|
||||
app.include_router(websocket.router, prefix="/ws", tags=["websocket"])
|
||||
app.include_router(notes.router, prefix="/notepad", tags=["notes"])
|
||||
app.include_router(files.router, prefix="/notepad", tags=["files"])
|
||||
app.include_router(websocket.router, prefix="/notepad/ws", tags=["websocket"])
|
||||
|
||||
|
||||
@app.get("/notepad/health")
|
||||
async def health_check():
|
||||
return {"status": "healthy"}
|
||||
|
||||
|
||||
# 静态文件服务(用于生产环境)
|
||||
if os.path.exists("./static"):
|
||||
app.mount("/", StaticFiles(directory="./static", html=True), name="static")
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""健康检查端点"""
|
||||
return {"status": "healthy"}
|
||||
|
||||
if __name__ == "__main__":
|
||||
settings = get_settings()
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
reload=True,
|
||||
log_level="info"
|
||||
)
|
||||
port = int(os.getenv("PORT", 7024))
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=port, reload=True, log_level="info")
|
||||
|
||||
@ -9,18 +9,17 @@ file_service = FileService()
|
||||
auth_service = AuthService()
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
async def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
"""验证JWT令牌"""
|
||||
token = credentials.credentials
|
||||
|
||||
if not auth_service.validate_token(token):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Invalid or expired token"
|
||||
)
|
||||
raise HTTPException(status_code=403, detail="Invalid or expired token")
|
||||
|
||||
return token
|
||||
|
||||
|
||||
@router.post("/verify-password", response_model=AuthToken)
|
||||
async def verify_password(request: PasswordRequest):
|
||||
"""验证访问口令"""
|
||||
@ -32,26 +31,22 @@ async def verify_password(request: PasswordRequest):
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/list", response_model=FileListResponse, dependencies=[Depends(verify_token)])
|
||||
async def get_files_list(
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
limit: int = Query(50, ge=1, le=100, description="每页文件数")
|
||||
page: int = Query(1, ge=1, description="页码"), limit: int = Query(50, ge=1, le=100, description="每页文件数")
|
||||
):
|
||||
"""获取文件列表(需要认证)"""
|
||||
try:
|
||||
files = file_service.list_files(page, limit)
|
||||
total = file_service.get_total_files_count()
|
||||
|
||||
return FileListResponse(
|
||||
files=files,
|
||||
total=total,
|
||||
page=page,
|
||||
limit=limit
|
||||
)
|
||||
return FileListResponse(files=files, total=total, page=page, limit=limit)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.delete("/{filename}")
|
||||
|
||||
@router.post("/{filename}/delete")
|
||||
async def delete_file(filename: str):
|
||||
"""删除文件"""
|
||||
try:
|
||||
@ -65,12 +60,16 @@ async def delete_file(filename: str):
|
||||
except Exception as 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="新文件名")):
|
||||
"""重命名文件"""
|
||||
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):
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
return {"message": f"File renamed to {new_filename}"}
|
||||
|
||||
if file_service.file_exists(new_filename):
|
||||
raise HTTPException(status_code=400, detail="File with new name already exists")
|
||||
|
||||
@ -7,12 +7,12 @@ from ..services.file_service import FileService
|
||||
router = APIRouter()
|
||||
file_service = FileService()
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
def __init__(self):
|
||||
self.active_connections: Dict[str, WebSocket] = {}
|
||||
|
||||
async def connect(self, websocket: WebSocket, filename: str):
|
||||
await websocket.accept()
|
||||
self.active_connections[filename] = websocket
|
||||
|
||||
def disconnect(self, filename: str):
|
||||
@ -28,63 +28,66 @@ class ConnectionManager:
|
||||
# 连接已断开,清理
|
||||
self.disconnect(filename)
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
|
||||
@router.websocket("/{filename}")
|
||||
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:
|
||||
await websocket.accept()
|
||||
await manager.connect(websocket, filename)
|
||||
print(f"WebSocket connected for file: {filename}")
|
||||
# print(f"WebSocket connected for file: {filename}")
|
||||
except Exception as e:
|
||||
print(f"WebSocket connection failed: {e}")
|
||||
await websocket.close(code=1006)
|
||||
# print(f"WebSocket connection failed: {e}")
|
||||
# print(f"Exception type: {type(e)}")
|
||||
try:
|
||||
await websocket.close(code=1006)
|
||||
except:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
while True:
|
||||
# 接收客户端消息
|
||||
data = await websocket.receive_text()
|
||||
message = json.loads(data)
|
||||
|
||||
# 处理保存请求
|
||||
if message.get("type") == "save":
|
||||
try:
|
||||
content = message.get("content", "")
|
||||
|
||||
# 检查内容是否为空
|
||||
if not content or content.strip() == "":
|
||||
# 删除空文件
|
||||
if file_service.file_exists(filename):
|
||||
file_service.delete_file(filename)
|
||||
response = {
|
||||
"type": "save_response",
|
||||
"status": "success",
|
||||
"message": "空文件已删除",
|
||||
"timestamp": message.get("timestamp")
|
||||
"timestamp": message.get("timestamp"),
|
||||
}
|
||||
else:
|
||||
response = {
|
||||
"type": "save_response",
|
||||
"status": "success",
|
||||
"message": "文件不存在",
|
||||
"timestamp": message.get("timestamp")
|
||||
"timestamp": message.get("timestamp"),
|
||||
}
|
||||
else:
|
||||
# 保存非空文件
|
||||
file_service.write_file(filename, content)
|
||||
response = {
|
||||
"type": "save_response",
|
||||
"status": "success",
|
||||
"message": "保存成功",
|
||||
"timestamp": message.get("timestamp")
|
||||
"timestamp": message.get("timestamp"),
|
||||
}
|
||||
except Exception as e:
|
||||
# 发送失败响应
|
||||
response = {
|
||||
"type": "save_response",
|
||||
"status": "error",
|
||||
"message": str(e),
|
||||
"timestamp": message.get("timestamp")
|
||||
"timestamp": message.get("timestamp"),
|
||||
}
|
||||
|
||||
await manager.send_message(response, filename)
|
||||
@ -92,10 +95,6 @@ async def websocket_endpoint(websocket: WebSocket, filename: str):
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(filename)
|
||||
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)
|
||||
manager.disconnect(filename)
|
||||
@ -4,29 +4,24 @@ import jwt
|
||||
from ..models.text_file import AuthToken
|
||||
from ..utils.config import get_settings
|
||||
|
||||
|
||||
class AuthService:
|
||||
def __init__(self):
|
||||
self.settings = get_settings()
|
||||
|
||||
def verify_password(self, password: str) -> AuthToken:
|
||||
"""验证口令并生成令牌"""
|
||||
if password != self.settings.file_list_password:
|
||||
raise ValueError("Invalid password")
|
||||
|
||||
# 生成JWT令牌
|
||||
expires_delta = timedelta(hours=self.settings.jwt_expire_hours)
|
||||
expires_delta = timedelta(minutes=self.settings.jwt_expire_minutes)
|
||||
expires_at = datetime.utcnow() + expires_delta
|
||||
|
||||
to_encode = {
|
||||
payload = {
|
||||
"exp": expires_at,
|
||||
"sub": "file_list_access"
|
||||
}
|
||||
|
||||
token = jwt.encode(
|
||||
to_encode,
|
||||
self.settings.jwt_secret_key,
|
||||
algorithm=self.settings.jwt_algorithm
|
||||
)
|
||||
token = jwt.encode(payload, self.settings.jwt_secret_key, algorithm="HS256")
|
||||
|
||||
return AuthToken(
|
||||
token=token,
|
||||
@ -34,13 +29,8 @@ class AuthService:
|
||||
)
|
||||
|
||||
def validate_token(self, token: str) -> bool:
|
||||
"""验证JWT令牌"""
|
||||
try:
|
||||
jwt.decode(
|
||||
token,
|
||||
self.settings.jwt_secret_key,
|
||||
algorithms=[self.settings.jwt_algorithm]
|
||||
)
|
||||
jwt.decode(token, self.settings.jwt_secret_key, algorithms=["HS256"])
|
||||
return True
|
||||
except jwt.PyJWTError:
|
||||
except Exception:
|
||||
return False
|
||||
@ -6,38 +6,41 @@ from pathlib import Path
|
||||
from ..models.text_file import TextFile, FileListItem
|
||||
from ..utils.config import get_settings
|
||||
|
||||
|
||||
class FileService:
|
||||
def __init__(self):
|
||||
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)
|
||||
|
||||
def _validate_filename(self, filename: str) -> bool:
|
||||
"""验证文件名是否安全"""
|
||||
# 检查文件名长度
|
||||
if len(filename) > 200 or len(filename) < 1:
|
||||
return False
|
||||
|
||||
# 检查文件名是否包含非法字符
|
||||
illegal_chars = r'[<>:"/\\|?*\x00-\x1f]'
|
||||
if re.search(illegal_chars, filename):
|
||||
illegal_chars = r'[^a-zA-Z0-9_.-]'
|
||||
if (
|
||||
re.search(illegal_chars, filename)
|
||||
or filename.startswith("_")
|
||||
or filename.startswith(".")
|
||||
or filename.startswith("-")
|
||||
):
|
||||
return False
|
||||
|
||||
# 检查路径遍历攻击
|
||||
if '..' in filename or filename.startswith('/'):
|
||||
return False
|
||||
|
||||
# 确保文件扩展名为.txt
|
||||
if not filename.lower().endswith('.txt'):
|
||||
return False
|
||||
|
||||
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:
|
||||
"""读取文件内容"""
|
||||
if not self._validate_filename(filename):
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
|
||||
filename = self._add_txt_extension(filename)
|
||||
file_path = self.notes_dir / filename
|
||||
|
||||
if not file_path.exists():
|
||||
@ -53,7 +56,7 @@ class FileService:
|
||||
content=content,
|
||||
created_at=datetime.fromtimestamp(stat.st_ctime),
|
||||
updated_at=datetime.fromtimestamp(stat.st_mtime),
|
||||
size=len(content)
|
||||
size=len(content),
|
||||
)
|
||||
except UnicodeDecodeError:
|
||||
raise ValueError(f"File {filename} is not a valid text file")
|
||||
@ -61,7 +64,6 @@ class FileService:
|
||||
raise RuntimeError(f"Error reading file {filename}: {str(e)}")
|
||||
|
||||
def write_file(self, filename: str, content: str) -> TextFile:
|
||||
"""写入文件内容"""
|
||||
if not self._validate_filename(filename):
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
|
||||
@ -71,6 +73,7 @@ class FileService:
|
||||
if len(content) > 100000:
|
||||
raise ValueError("File content too large (max 100,000 characters)")
|
||||
|
||||
filename = self._add_txt_extension(filename)
|
||||
file_path = self.notes_dir / filename
|
||||
|
||||
try:
|
||||
@ -83,7 +86,7 @@ class FileService:
|
||||
content=content,
|
||||
created_at=datetime.fromtimestamp(stat.st_ctime),
|
||||
updated_at=datetime.fromtimestamp(stat.st_mtime),
|
||||
size=len(content)
|
||||
size=len(content),
|
||||
)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Error writing file {filename}: {str(e)}")
|
||||
@ -93,7 +96,6 @@ class FileService:
|
||||
return self.write_file(filename, "")
|
||||
|
||||
def list_files(self, page: int = 1, limit: int = 50) -> List[FileListItem]:
|
||||
"""列出文件"""
|
||||
if page < 1:
|
||||
page = 1
|
||||
|
||||
@ -106,13 +108,11 @@ class FileService:
|
||||
for file_path in self.notes_dir.glob("*.txt"):
|
||||
stat = file_path.stat()
|
||||
|
||||
# 读取文件前100个字符作为预览
|
||||
preview = ""
|
||||
if stat.st_size > 0:
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read(100)
|
||||
# 清理预览内容,移除换行符
|
||||
preview = content.replace('\n', ' ').replace('\r', '').strip()
|
||||
if len(preview) > 50:
|
||||
preview = preview[:50] + "..."
|
||||
@ -121,48 +121,47 @@ class FileService:
|
||||
except Exception:
|
||||
preview = ""
|
||||
|
||||
files.append(FileListItem(
|
||||
filename=file_path.name,
|
||||
created_at=datetime.fromtimestamp(stat.st_ctime),
|
||||
size=stat.st_size,
|
||||
preview=preview
|
||||
))
|
||||
files.append(
|
||||
FileListItem(
|
||||
filename=file_path.name,
|
||||
created_at=datetime.fromtimestamp(stat.st_ctime),
|
||||
size=stat.st_size,
|
||||
preview=preview,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Error listing files: {str(e)}")
|
||||
|
||||
# 按创建时间倒序排列
|
||||
files.sort(key=lambda x: x.created_at, reverse=True)
|
||||
|
||||
# 分页
|
||||
start = (page - 1) * limit
|
||||
end = start + limit
|
||||
|
||||
return files[start:end]
|
||||
|
||||
def get_total_files_count(self) -> int:
|
||||
"""获取文件总数"""
|
||||
try:
|
||||
return len(list(self.notes_dir.glob("*.txt")))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
def file_exists(self, filename: str) -> bool:
|
||||
"""检查文件是否存在"""
|
||||
if not self._validate_filename(filename):
|
||||
return False
|
||||
|
||||
filename = self._add_txt_extension(filename)
|
||||
file_path = self.notes_dir / filename
|
||||
return file_path.exists() and file_path.is_file()
|
||||
|
||||
def delete_file(self, filename: str) -> bool:
|
||||
"""删除文件"""
|
||||
if not self._validate_filename(filename):
|
||||
raise ValueError(f"Invalid filename: {filename}")
|
||||
|
||||
filename = self._add_txt_extension(filename)
|
||||
file_path = self.notes_dir / filename
|
||||
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"File {filename} not found")
|
||||
return True # 文件已不存在
|
||||
|
||||
try:
|
||||
file_path.unlink()
|
||||
@ -171,18 +170,20 @@ class FileService:
|
||||
raise RuntimeError(f"Error deleting file {filename}: {str(e)}")
|
||||
|
||||
def rename_file(self, old_filename: str, new_filename: str) -> bool:
|
||||
"""重命名文件"""
|
||||
if not self._validate_filename(old_filename):
|
||||
raise ValueError(f"Invalid old filename: {old_filename}")
|
||||
|
||||
if not self._validate_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
|
||||
new_path = self.notes_dir / new_filename
|
||||
|
||||
if not old_path.exists():
|
||||
raise FileNotFoundError(f"File {old_filename} not found")
|
||||
return True
|
||||
|
||||
if new_path.exists():
|
||||
raise ValueError(f"File {new_filename} already exists")
|
||||
|
||||
@ -1,41 +1,30 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import ConfigDict
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""应用配置"""
|
||||
|
||||
# 基础配置
|
||||
app_name: str = os.getenv("APP_NAME", "Vue3 + Python Notepad")
|
||||
|
||||
# 文件存储配置
|
||||
notes_dir: str = "/app/data"
|
||||
|
||||
# 安全配置
|
||||
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配置(使用固定值,简化配置)
|
||||
jwt_secret_key: str = "default-secret-key-change-in-production"
|
||||
jwt_secret_key: str = os.getenv("JWT_SECRET_KEY", "default-secret-key-change-in-production")
|
||||
jwt_algorithm: str = "HS256"
|
||||
jwt_expire_minutes: int = 1440 # 24小时
|
||||
|
||||
# 文件限制(使用固定值,简化配置)
|
||||
max_file_size: int = 100000 # 100KB
|
||||
jwt_expire_minutes: int = 1440
|
||||
max_file_size: int = 100000
|
||||
max_filename_length: int = 200
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
# 创建全局设置实例
|
||||
settings = Settings()
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
return settings
|
||||
|
||||
# 确保数据目录存在
|
||||
def ensure_data_directory():
|
||||
os.makedirs(settings.notes_dir, exist_ok=True)
|
||||
|
||||
# 在导入时创建目录
|
||||
def ensure_data_directory():
|
||||
os.makedirs(settings.notes_path, exist_ok=True)
|
||||
|
||||
|
||||
ensure_data_directory()
|
||||
@ -1,38 +1,20 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
notepad:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: notepad-backend
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ${FILES_DIR:-./files}:/app/data
|
||||
env_file:
|
||||
- .env
|
||||
networks:
|
||||
- notepad-network
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: notepad-frontend
|
||||
context: .
|
||||
args:
|
||||
- APP_NAME=${APP_NAME:-Notepad}
|
||||
container_name: notepad
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${EXTERNAL_PORT:-80}:80"
|
||||
depends_on:
|
||||
- backend
|
||||
- "${PORT:-80}:80"
|
||||
env_file:
|
||||
- .env
|
||||
networks:
|
||||
- notepad-network
|
||||
|
||||
networks:
|
||||
notepad-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
notepad-data:
|
||||
driver: local
|
||||
volumes:
|
||||
- ${NOTES_DIR:-./data}:/app/data/notes
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:${PORT:-80}/notepad/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
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">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vue3 + Python Notepad</title>
|
||||
<title>%VITE_APP_TITLE%</title>
|
||||
</head>
|
||||
<body>
|
||||
<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",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import axios from 'axios'
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
@ -21,7 +21,10 @@ api.interceptors.response.use(
|
||||
if (error.response?.status === 401) {
|
||||
// 令牌过期,清除本地存储
|
||||
localStorage.removeItem('authToken')
|
||||
window.location.reload()
|
||||
// 只在非密码验证页面刷新
|
||||
if (!error.config.url?.includes('verify-password')) {
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
@ -39,6 +42,7 @@ export interface FileListItem {
|
||||
filename: string
|
||||
created_at: string
|
||||
size: number
|
||||
preview?: string
|
||||
}
|
||||
|
||||
export interface AuthToken {
|
||||
@ -54,22 +58,22 @@ export interface FileListResponse {
|
||||
}
|
||||
|
||||
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) =>
|
||||
api.post<TextFile>(`/notes/${filename}`, { content })
|
||||
api.post<TextFile>(`/notepad/notes/${filename}`, { content })
|
||||
}
|
||||
|
||||
export const filesApi = {
|
||||
getList: (page = 1, limit = 50) =>
|
||||
api.get<FileListResponse>(
|
||||
'/files/list', { params: { page, limit } }
|
||||
'/notepad/files/list', { params: { page, limit } }
|
||||
),
|
||||
verifyPassword: (password: string) =>
|
||||
api.post<AuthToken>('/files/verify-password', { password }),
|
||||
api.post<AuthToken>('/notepad/files/verify-password', { password }),
|
||||
deleteFile: (filename: string) =>
|
||||
api.delete(`/files/${filename}`),
|
||||
api.post(`/notepad/files/${filename}/delete`),
|
||||
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
|
||||
@ -6,58 +6,133 @@ export function useWebSocket() {
|
||||
const reconnectAttempts = ref(0)
|
||||
const maxReconnectAttempts = 5
|
||||
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) => {
|
||||
if (ws) {
|
||||
ws.close()
|
||||
if (isConnecting && currentFilename === filename) return
|
||||
|
||||
if (reconnectTimeout) {
|
||||
clearTimeout(reconnectTimeout)
|
||||
reconnectTimeout = null
|
||||
}
|
||||
|
||||
// 使用相对路径,通过 Vite 代理
|
||||
const wsUrl = `/ws/${filename}`
|
||||
if (import.meta.env.DEV) console.log(`Connecting to WebSocket at: ${wsUrl}`)
|
||||
if (ws) {
|
||||
const oldWs = ws
|
||||
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 {
|
||||
isConnecting = true
|
||||
ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectAttempts.value = 0
|
||||
// 只在开发环境输出
|
||||
if (import.meta.env.DEV) console.log('WebSocket connected')
|
||||
isConnecting = false
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
// 只在开发环境输出
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('📨 WebSocket message received:', event.data)
|
||||
}
|
||||
const message = JSON.parse(event.data)
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('📨 Parsed message:', message)
|
||||
}
|
||||
handleMessage(message)
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
// 只在开发环境输出
|
||||
if (import.meta.env.DEV) console.log('WebSocket closed:', event.code, event.reason)
|
||||
if (reconnectAttempts.value < maxReconnectAttempts) {
|
||||
setTimeout(() => {
|
||||
isConnecting = false
|
||||
|
||||
if (currentFilename === filename && reconnectAttempts.value < maxReconnectAttempts) {
|
||||
reconnectTimeout = window.setTimeout(() => {
|
||||
reconnectAttempts.value++
|
||||
if (import.meta.env.DEV) console.log(`Reconnecting... attempt ${reconnectAttempts.value}`)
|
||||
connect(filename)
|
||||
}, 1000 * reconnectAttempts.value)
|
||||
} else {
|
||||
console.error('Max reconnection attempts reached')
|
||||
editorStore.setSaveStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error)
|
||||
isConnecting = false
|
||||
editorStore.setSaveStatus('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')
|
||||
}
|
||||
}
|
||||
@ -69,31 +144,43 @@ export function useWebSocket() {
|
||||
content,
|
||||
timestamp: new Date().toISOString()
|
||||
}))
|
||||
} else if (usePolling) {
|
||||
saveViaHttp(filename, content)
|
||||
} else {
|
||||
startPolling(filename)
|
||||
saveViaHttp(filename, content)
|
||||
}
|
||||
}
|
||||
|
||||
const disconnect = () => {
|
||||
if (reconnectTimeout) {
|
||||
clearTimeout(reconnectTimeout)
|
||||
reconnectTimeout = null
|
||||
}
|
||||
|
||||
if (ws) {
|
||||
ws.onclose = null
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
|
||||
stopPolling()
|
||||
|
||||
isConnecting = false
|
||||
currentFilename = ''
|
||||
}
|
||||
|
||||
const handleMessage = (message: any) => {
|
||||
if (import.meta.env.DEV) console.log('🔄 Handling WebSocket message:', message)
|
||||
switch (message.type) {
|
||||
case 'save_response':
|
||||
if (import.meta.env.DEV) console.log('✅ Save response received:', message.status)
|
||||
editorStore.setSaveStatus(
|
||||
message.status === 'success' ? 'saved' : 'error'
|
||||
)
|
||||
break
|
||||
case 'error':
|
||||
console.error('❌ WebSocket error:', message.message)
|
||||
editorStore.setSaveStatus('error')
|
||||
break
|
||||
default:
|
||||
if (import.meta.env.DEV) console.log('❓ Unknown message type:', message.type)
|
||||
case 'save_response':
|
||||
editorStore.setSaveStatus(
|
||||
message.status === 'success' ? 'saved' : 'error'
|
||||
)
|
||||
break
|
||||
case 'error':
|
||||
console.error('WebSocket error:', message.message)
|
||||
editorStore.setSaveStatus('error')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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 { notesApi, filesApi } from '@/services/api'
|
||||
import { useWebSocket } from '@/services/websocket'
|
||||
import { emitter } from '@/utils/eventBus'
|
||||
import StatusIndicator from '@/components/common/StatusIndicator.vue'
|
||||
|
||||
const route = useRoute()
|
||||
@ -149,12 +150,8 @@ const handleInput = () => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
if (testingMode.value) console.log('✅ Using WebSocket to save')
|
||||
try {
|
||||
// 确保文件名包含 .txt 后缀
|
||||
const filename = editorStore.currentFile.toLowerCase().endsWith('.txt')
|
||||
? 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('Sending to WebSocket with filename:', editorStore.currentFile)
|
||||
sendSave(editorStore.currentFile, editorStore.content)
|
||||
if (testingMode.value) console.log('WebSocket message sent, waiting for response...')
|
||||
// 设置超时,如果 WebSocket 5秒内没有响应,回退到 HTTP
|
||||
setTimeout(() => {
|
||||
@ -197,7 +194,7 @@ const cancelRename = () => {
|
||||
}
|
||||
|
||||
const deleteFile = async () => {
|
||||
if (!confirm(`确定要删除文件 "${displayName}" 吗?此操作不可恢复。`)) {
|
||||
if (!confirm(`确定要删除文件 "${displayName.value}" 吗?此操作不可恢复。`)) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -217,14 +214,9 @@ const deleteFile = async () => {
|
||||
}
|
||||
|
||||
const testSave = async () => {
|
||||
// 确保文件名包含 .txt 后缀
|
||||
const filename = editorStore.currentFile.toLowerCase().endsWith('.txt')
|
||||
? editorStore.currentFile
|
||||
: `${editorStore.currentFile}.txt`
|
||||
|
||||
if (testingMode.value) {
|
||||
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 preview:', editorStore.content.substring(0, 100))
|
||||
}
|
||||
@ -232,7 +224,7 @@ const testSave = async () => {
|
||||
editorStore.setSaveStatus('saving')
|
||||
|
||||
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)
|
||||
editorStore.setSaveStatus('saved')
|
||||
if (testingMode.value) console.log('✅ Test save successful!')
|
||||
@ -275,47 +267,49 @@ const confirmRename = async () => {
|
||||
|
||||
try {
|
||||
const oldFilename = editorStore.currentFile
|
||||
const newFullFilename = newFilename.value.toLowerCase().endsWith('.txt')
|
||||
? newFilename.value
|
||||
: `${newFilename.value}.txt`
|
||||
|
||||
await filesApi.renameFile(oldFilename, newFullFilename)
|
||||
// 先断开当前WebSocket连接
|
||||
disconnect()
|
||||
|
||||
// 更新当前文件名
|
||||
editorStore.setCurrentFile(newFullFilename)
|
||||
await filesApi.renameFile(oldFilename, newFilename.value)
|
||||
|
||||
// 重命名成功,更新当前文件名
|
||||
editorStore.setCurrentFile(newFilename.value)
|
||||
|
||||
// 更新URL
|
||||
router.replace(`/notes/${getDisplayName(newFullFilename)}`)
|
||||
router.replace(`/notes/${newFilename.value}`)
|
||||
|
||||
// 连接到新的WebSocket
|
||||
connect(newFilename.value)
|
||||
|
||||
cancelRename()
|
||||
} catch (err: any) {
|
||||
console.error('Rename error:', err)
|
||||
renameError.value = err.response?.data?.detail || '重命名失败'
|
||||
|
||||
// 重命名失败,重连到旧的WebSocket
|
||||
const oldFilename = editorStore.currentFile
|
||||
connect(oldFilename)
|
||||
}
|
||||
}
|
||||
|
||||
const saveViaHttp = async () => {
|
||||
try {
|
||||
// 确保文件名包含 .txt 后缀
|
||||
const filename = editorStore.currentFile.toLowerCase().endsWith('.txt')
|
||||
? editorStore.currentFile
|
||||
: `${editorStore.currentFile}.txt`
|
||||
|
||||
// 检查内容是否为空
|
||||
if (!editorStore.content || editorStore.content.trim() === '') {
|
||||
if (testingMode.value) console.log('Content is empty, deleting file:', filename)
|
||||
await filesApi.deleteFile(filename)
|
||||
if (testingMode.value) console.log('Content is empty, deleting file:', editorStore.currentFile)
|
||||
await filesApi.deleteFile(editorStore.currentFile)
|
||||
editorStore.setSaveStatus('saved')
|
||||
if (testingMode.value) console.log('Empty file deleted successfully')
|
||||
return
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
editorStore.setSaveStatus('saved')
|
||||
@ -343,14 +337,11 @@ const saveViaHttp = async () => {
|
||||
}
|
||||
|
||||
const loadFile = async (filename: string) => {
|
||||
// 确保文件名包含 .txt 后缀
|
||||
const fullFilename = filename.toLowerCase().endsWith('.txt') ? filename : `${filename}.txt`
|
||||
|
||||
editorStore.setLoading(true)
|
||||
editorStore.setCurrentFile(fullFilename)
|
||||
editorStore.setCurrentFile(filename)
|
||||
|
||||
try {
|
||||
const response = await notesApi.getFile(fullFilename)
|
||||
const response = await notesApi.getFile(filename)
|
||||
editorStore.setContent(response.data.content)
|
||||
editorStore.setSaveStatus('saved')
|
||||
} catch (error) {
|
||||
@ -366,21 +357,30 @@ onMounted(() => {
|
||||
editorElement.value?.focus()
|
||||
const filename = route.params.filename as string
|
||||
loadFile(filename)
|
||||
// 确保WebSocket连接使用完整的文件名(包含.txt)
|
||||
const fullFilename = filename.toLowerCase().endsWith('.txt') ? filename : `${filename}.txt`
|
||||
connect(fullFilename)
|
||||
connect(filename)
|
||||
|
||||
// 监听来自FileListView的事件
|
||||
emitter.on('disconnect-websocket', () => {
|
||||
disconnect()
|
||||
})
|
||||
|
||||
emitter.on('connect-websocket', (filename: string) => {
|
||||
connect(filename)
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disconnect()
|
||||
|
||||
// 清理事件监听器
|
||||
emitter.off('disconnect-websocket', () => {})
|
||||
emitter.off('connect-websocket', () => {})
|
||||
})
|
||||
|
||||
watch(() => route.params.filename, (newFilename) => {
|
||||
const filename = newFilename as string
|
||||
loadFile(filename)
|
||||
// 确保WebSocket连接使用完整的文件名(包含.txt)
|
||||
const fullFilename = filename.toLowerCase().endsWith('.txt') ? filename : `${filename}.txt`
|
||||
connect(fullFilename)
|
||||
connect(filename)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@ -53,7 +53,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<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">
|
||||
退出
|
||||
@ -79,7 +79,7 @@
|
||||
<p v-else>
|
||||
还没有文件,创建您的第一个文件吧!
|
||||
</p>
|
||||
<RouterLink to="/notes/new-file" class="btn btn-primary">
|
||||
<RouterLink to="/" class="btn btn-primary">
|
||||
创建文件
|
||||
</RouterLink>
|
||||
</div>
|
||||
@ -168,6 +168,7 @@
|
||||
import { ref, onMounted, nextTick, watch } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { filesApi, type FileListItem } from '@/services/api'
|
||||
import { emitter } from '@/utils/eventBus'
|
||||
|
||||
const router = useRouter()
|
||||
const files = ref<FileListItem[]>([])
|
||||
@ -192,7 +193,7 @@ const fetchFiles = async () => {
|
||||
const response = await filesApi.getList()
|
||||
files.value = response.data.files
|
||||
filterFiles()
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 401) {
|
||||
// 需要认证
|
||||
isAuthenticated.value = false
|
||||
@ -234,7 +235,7 @@ const verifyPassword = async () => {
|
||||
localStorage.setItem('authToken', response.data.token)
|
||||
isAuthenticated.value = true
|
||||
await fetchFiles()
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || '口令验证失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
@ -260,7 +261,7 @@ const deleteFile = async (filename: string) => {
|
||||
// 从列表中移除文件
|
||||
files.value = files.value.filter(f => f.filename !== filename)
|
||||
// filterFiles 会自动更新 filteredFiles
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || '删除文件失败'
|
||||
} finally {
|
||||
deleting.value = null
|
||||
@ -291,26 +292,51 @@ const confirmRename = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 确保文件名以 .txt 结尾
|
||||
const finalFilename = newFilename.value.toLowerCase().endsWith('.txt')
|
||||
? newFilename.value
|
||||
: `${newFilename.value}.txt`
|
||||
|
||||
renaming.value = true
|
||||
renameError.value = null
|
||||
|
||||
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)
|
||||
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()
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
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 {
|
||||
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: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8000',
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
host: '0.0.0.0',
|
||||
port: 5173
|
||||
},
|
||||
build: {
|
||||
outDir: '../backend/static'
|
||||
outDir: 'dist'
|
||||
}
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user