fix bug & update deployment

This commit is contained in:
yuany3721 2025-12-28 23:06:05 +08:00
parent 94cd289b66
commit 4302b9fd31
34 changed files with 698 additions and 3624 deletions

View File

@ -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=

View File

@ -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 容器用户权限

View File

@ -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
- APIhttp://localhost/api
- WebSocketws://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
View 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
View File

@ -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
## 许可证 ## 许可证

View File

@ -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

View File

@ -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"]

View File

@ -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"
)

View File

@ -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,26 +31,22 @@ 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:
@ -65,12 +60,16 @@ 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")

View File

@ -7,12 +7,12 @@ 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):
@ -28,63 +28,66 @@ 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}")
# print(f"Exception type: {type(e)}")
try:
await websocket.close(code=1006) 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)
@ -92,10 +95,6 @@ async def websocket_endpoint(websocket: WebSocket, filename: str):
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)

View File

@ -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

View File

@ -6,38 +6,41 @@ 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():
@ -53,7 +56,7 @@ class FileService:
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")
@ -61,7 +64,6 @@ class FileService:
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}")
@ -71,6 +73,7 @@ class FileService:
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:
@ -83,7 +86,7 @@ class FileService:
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)}")
@ -93,7 +96,6 @@ class FileService:
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
@ -106,13 +108,11 @@ class FileService:
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] + "..."
@ -121,48 +121,47 @@ class FileService:
except Exception: except Exception:
preview = "" preview = ""
files.append(FileListItem( files.append(
FileListItem(
filename=file_path.name, filename=file_path.name,
created_at=datetime.fromtimestamp(stat.st_ctime), created_at=datetime.fromtimestamp(stat.st_ctime),
size=stat.st_size, size=stat.st_size,
preview=preview 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()
@ -171,18 +170,20 @@ class FileService:
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")

View File

@ -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): 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", "")
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") 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配置使用固定值简化配置
jwt_secret_key: str = "default-secret-key-change-in-production"
jwt_algorithm: str = "HS256" jwt_algorithm: str = "HS256"
jwt_expire_minutes: int = 1440 # 24小时 jwt_expire_minutes: int = 1440
max_file_size: int = 100000
# 文件限制(使用固定值,简化配置)
max_file_size: int = 100000 # 100KB
max_filename_length: int = 200 max_filename_length: int = 200
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
# 创建全局设置实例
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():
os.makedirs(settings.notes_path, exist_ok=True)
ensure_data_directory() ensure_data_directory()

View File

@ -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
View 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
View 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
View 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
View 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

View File

@ -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的网页基本功能开发

View File

@ -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

View File

@ -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部署架构和配置
- 安全策略和性能优化方案

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -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>

View File

@ -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";
}
}
}

View File

@ -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",

View File

@ -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,8 +21,11 @@ api.interceptors.response.use(
if (error.response?.status === 401) { if (error.response?.status === 401) {
// 令牌过期,清除本地存储 // 令牌过期,清除本地存储
localStorage.removeItem('authToken') localStorage.removeItem('authToken')
// 只在非密码验证页面刷新
if (!error.config.url?.includes('verify-password')) {
window.location.reload() 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

View File

@ -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)
} }
} }

View 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()

View File

@ -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>

View File

@ -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
} }

File diff suppressed because one or more lines are too long

View File

@ -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'
} }
}) })