VitePress 在线编辑功能开发完整教程
前言
这个教程将指导您为 VitePress 笔记网站添加在线编辑功能,包括:
- 用户认证系统
- 汉堡菜单界面
- Markdown 在线编辑器
- 版本控制和冲突检测
- 草稿箱功能
重要提醒: 在开始之前,请确保备份您当前的工作,充分利用 Git 版本控制来保护代码。
目录
项目安全准备
1. 创建开发分支保护现有工作
在开始任何开发之前,我们需要保护您现有的工作:
1.1 检查当前状态
git status
含义: 查看当前工作目录的状态,确认是否有未提交的更改
1.2 提交当前所有更改
git add .
git commit -m "保存当前稳定版本 - 准备开发在线编辑功能"
含义: 将当前所有更改提交到 Git,作为一个稳定的保存点
1.3 创建功能开发分支
git checkout -b feature/online-editor
含义: 创建并切换到新分支 feature/online-editor
,所有在线编辑功能开发都在这个分支进行
1.4 推送分支到服务器
git push -u origin feature/online-editor
含义: 将新分支推送到服务器,建立远程跟踪关系
2. 服务器端分支配置
2.1 连接到服务器
ssh webnote@43.159.60.248
含义: 连接到您的服务器
2.2 在服务器上创建开发环境
cd /home/webnote/webnote/source/WebNote
git fetch origin
git checkout -b feature/online-editor origin/feature/online-editor
含义: 在服务器上同步并切换到开发分支
2.3 创建开发端口配置
sudo ufw allow 3001
含义: 为 API 服务器开放 3001 端口
技术架构设计
整体架构图
┌─────────────────────────────────────────────────────────────┐
│ 前端用户界面 │
├─────────────────────────────────────────────────────────────┤
│ VitePress (8080) + 汉堡菜单 + Monaco Editor │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ 登录界面 │ │ 编辑界面 │ │ 草稿箱管理界面 │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ API 中间层 (3001) │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ 认证服务 │ │ 文件操作API │ │ 版本控制服务 │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ 存储和版本控制 │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ Git 仓库 │ │ 用户数据 │ │ 草稿存储 │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
端口分配
- 8080: Nginx 静态文件服务(生产环境)
- 3000: VitePress 开发服务器(开发预览)
- 3001: API 服务器(在线编辑功能)
数据流设计
- 用户认证流程: 前端 → API认证 → JWT令牌 → 后续请求携带令牌
- 文件编辑流程: 读取文件 → 在线编辑 → 保存草稿/提交 → Git提交 → 自动部署
- 冲突检测流程: 获取文件版本 → 编辑时检查版本 → 冲突提示 → 合并或覆盖
第一阶段:开发分支管理
1. 创建项目结构
1.1 在本地创建 API 目录结构
mkdir -p api/{routes,middleware,models,utils,config}
mkdir -p docs/.vitepress/components/editor
mkdir -p docs/.vitepress/stores
含义: 创建完整的项目结构
api/
: API 服务器代码docs/.vitepress/components/editor/
: 编辑器相关组件docs/.vitepress/stores/
: 状态管理
1.2 创建配置文件占位符
touch api/package.json
touch api/server.js
touch api/config/auth.js
touch api/config/database.js
touch docs/.vitepress/components/editor/HamburgerMenu.vue
touch docs/.vitepress/components/editor/MarkdownEditor.vue
touch docs/.vitepress/stores/auth.js
含义: 创建主要文件的占位符,为后续开发做准备
1.3 提交初始结构
git add .
git commit -m "添加在线编辑功能的项目结构"
含义: 保存项目结构到 Git
2. 依赖包规划
我们需要安装的主要依赖包:
API 服务器依赖:
express
: Web 框架express-session
: 会话管理bcryptjs
: 密码加密jsonwebtoken
: JWT 认证multer
: 文件上传处理cors
: 跨域请求处理simple-git
: Git 操作chokidar
: 文件变化监听fs-extra
: 文件系统操作增强
前端依赖:
monaco-editor
: 代码编辑器axios
: HTTP 客户端pinia
: 状态管理(如果使用 Vue 3)
3. 开发环境隔离
3.1 修改开发配置以支持 API 代理
我们需要配置 VitePress 开发服务器代理 API 请求到我们的 Express 服务器。
3.2 创建环境变量文件
touch .env.development
touch .env.production
含义: 创建不同环境的配置文件
4. Git 工作流设置
4.1 配置 Git 忽略文件
echo "
# API 服务器
api/node_modules/
api/logs/
api/.env
# 开发环境
.env.development
.env.production
# 编辑器临时文件
*.tmp
*.draft
# 用户上传文件
uploads/
" >> .gitignore
含义: 更新 .gitignore 文件,排除不需要版本控制的文件
4.2 设置提交模板
git config commit.template .gitmessage
echo "
# 提交类型:
# feat: 新功能
# fix: 修复bug
# docs: 文档更新
# style: 代码格式调整
# refactor: 代码重构
# test: 测试相关
# chore: 构建过程或辅助工具的变动
# 修改内容简述:
#
# 详细说明(可选):
#
# 关联的 issue(可选):
#
" > .gitmessage
含义: 设置 Git 提交消息模板,规范提交格式
现在我们已经完成了第一阶段的准备工作。接下来的阶段将更加详细,包含具体的代码实现。
下一步预告: 第二阶段将开始 API 服务器的开发,包括认证系统、文件操作 API 和版本控制功能。
请确认第一阶段的步骤都已完成,然后我们继续下一阶段的开发
第二阶段:API 服务器开发
1. 初始化 API 服务器项目
1.1 进入 API 目录并初始化 Node.js 项目
cd api
npm init -y
含义: 初始化 Node.js 项目,创建 package.json 文件
1.2 安装基础依赖
npm install express cors dotenv helmet morgan
含义: 安装 Express 框架和基础中间件
express
: Web 应用框架cors
: 处理跨域请求dotenv
: 环境变量管理helmet
: 安全中间件morgan
: HTTP 请求日志
1.3 安装认证相关依赖
npm install bcryptjs jsonwebtoken express-session
含义: 安装用户认证相关的包
bcryptjs
: 密码哈希加密jsonwebtoken
: JWT 令牌生成和验证express-session
: 会话管理
1.4 安装文件操作依赖
npm install fs-extra multer simple-git chokidar
含义: 安装文件和 Git 操作相关的包
fs-extra
: 增强的文件系统操作multer
: 文件上传中间件simple-git
: Git 操作库chokidar
: 文件变化监听
1.5 安装开发依赖
npm install --save-dev nodemon concurrently
含义: 安装开发工具
nodemon
: 自动重启开发服务器concurrently
: 同时运行多个命令
2. 创建基础服务器结构
2.1 创建主服务器文件
cat > server.js << 'EOF'
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
require('dotenv').config();
const app = express();
const PORT = process.env.PORT || 3001;
// 基础中间件
app.use(helmet());
app.use(morgan('combined'));
app.use(cors({
origin: ['http://43.159.60.248:8080', 'http://43.159.60.248:3000'],
credentials: true
}));
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
// 健康检查端点
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime()
});
});
// 错误处理中间件
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({
error: 'Internal Server Error',
message: process.env.NODE_ENV === 'development' ? err.message : 'Something went wrong!'
});
});
// 404 处理
app.use('*', (req, res) => {
res.status(404).json({ error: 'API endpoint not found' });
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`API服务器启动在端口 ${PORT}`);
console.log(`健康检查: http://43.159.60.248:${PORT}/api/health`);
});
EOF
含义: 创建基础的 Express 服务器,包含安全中间件和基本路由
2.2 创建环境配置文件
cat > .env << 'EOF'
NODE_ENV=development
PORT=3001
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
SESSION_SECRET=your-super-secret-session-key-change-this-too
WORK_DIR=/home/webnote/webnote/source/WebNote
GIT_DIR=/home/webnote/webnote/webnote.git
BUILD_DIR=/home/webnote/webnote/build
EOF
含义: 创建环境变量配置文件
2.3 更新 package.json 添加脚本
cat > package.json << 'EOF'
{
"name": "webnote-api",
"version": "1.0.0",
"description": "VitePress在线编辑API服务器",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": ["vitepress", "markdown", "editor", "api"],
"author": "WebNote",
"license": "MIT",
"dependencies": {
"bcryptjs": "^2.4.3",
"chokidar": "^3.5.3",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-session": "^1.17.3",
"fs-extra": "^11.1.1",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"simple-git": "^3.19.1"
},
"devDependencies": {
"concurrently": "^8.2.2",
"nodemon": "^3.0.1"
}
}
EOF
含义: 配置 package.json,包含所有依赖和启动脚本
3. 创建认证配置
3.1 创建认证配置文件
cat > config/auth.js << 'EOF'
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
// 用户数据存储(生产环境应使用数据库)
const users = [
{
id: 1,
username: 'admin',
// 密码: admin123(请在生产环境中更改)
password: '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi',
role: 'admin',
displayName: '管理员'
},
{
id: 2,
username: 'editor',
// 密码: editor123(请在生产环境中更改)
password: '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi',
role: 'editor',
displayName: '编辑者'
}
];
// 验证用户凭据
const validateUser = async (username, password) => {
const user = users.find(u => u.username === username);
if (!user) return null;
const isValidPassword = await bcrypt.compare(password, user.password);
if (!isValidPassword) return null;
// 返回用户信息(不包含密码)
const { password: _, ...userInfo } = user;
return userInfo;
};
// 生成 JWT 令牌
const generateToken = (user) => {
return jwt.sign(
{
id: user.id,
username: user.username,
role: user.role
},
process.env.JWT_SECRET,
{ expiresIn: '24h' }
);
};
// 验证 JWT 令牌
const verifyToken = (token) => {
try {
return jwt.verify(token, process.env.JWT_SECRET);
} catch (error) {
return null;
}
};
// 生成新用户密码哈希(用于添加新用户)
const hashPassword = async (password) => {
return await bcrypt.hash(password, 10);
};
module.exports = {
users,
validateUser,
generateToken,
verifyToken,
hashPassword
};
EOF
含义: 创建用户认证配置,包含用户数据和认证函数
3.2 创建认证中间件
cat > middleware/auth.js << 'EOF'
const { verifyToken } = require('../config/auth');
// JWT 认证中间件
const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
if (!token) {
return res.status(401).json({ error: '访问令牌缺失' });
}
const user = verifyToken(token);
if (!user) {
return res.status(403).json({ error: '无效或过期的访问令牌' });
}
req.user = user;
next();
};
// 角色权限检查中间件
const requireRole = (roles) => {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: '未认证用户' });
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: '权限不足' });
}
next();
};
};
// 编辑权限检查(admin 和 editor 都可以编辑)
const requireEditPermission = requireRole(['admin', 'editor']);
module.exports = {
authenticateToken,
requireRole,
requireEditPermission
};
EOF
含义: 创建认证中间件,用于保护 API 路由
4. 创建认证路由
4.1 创建认证 API 路由
cat > routes/auth.js << 'EOF'
const express = require('express');
const { validateUser, generateToken } = require('../config/auth');
const { authenticateToken } = require('../middleware/auth');
const router = express.Router();
// 用户登录
router.post('/login', async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: '用户名和密码是必需的' });
}
const user = await validateUser(username, password);
if (!user) {
return res.status(401).json({ error: '用户名或密码错误' });
}
const token = generateToken(user);
res.json({
success: true,
token,
user: {
id: user.id,
username: user.username,
role: user.role,
displayName: user.displayName
}
});
} catch (error) {
console.error('登录错误:', error);
res.status(500).json({ error: '登录过程中发生错误' });
}
});
// 验证当前用户状态
router.get('/me', authenticateToken, (req, res) => {
res.json({
success: true,
user: req.user
});
});
// 用户登出(客户端删除令牌即可)
router.post('/logout', authenticateToken, (req, res) => {
res.json({ success: true, message: '已登出' });
});
module.exports = router;
EOF
含义: 创建用户认证相关的 API 路由
4.2 测试认证服务器
cd ..
npm run dev
含义: 启动开发服务器进行测试
4.3 在另一个终端测试健康检查
curl http://localhost:3001/api/health
含义: 测试 API 服务器是否正常运行
4.4 测试认证功能
# 用户登录
curl -X POST -H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123"}' \
http://localhost:3001/api/auth/login
# 获取当前用户信息
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:3001/api/auth/me
# 用户登出
curl -X POST -H "Authorization: Bearer YOUR_TOKEN" http://localhost:3001/api/auth/logout
含义: 测试用户认证相关的 API 是否正常
5. 集成认证路由到主服务器
5.1 更新主服务器文件
cat > api/server.js << 'EOF'
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
require('dotenv').config();
// 导入路由
const authRoutes = require('./routes/auth');
const app = express();
const PORT = process.env.PORT || 3001;
// 基础中间件
app.use(helmet());
app.use(morgan('combined'));
app.use(cors({
origin: ['http://43.159.60.248:8080', 'http://43.159.60.248:3000', 'http://localhost:3000'],
credentials: true
}));
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
// API 路由
app.use('/api/auth', authRoutes);
// 健康检查端点
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
version: '1.0.0',
features: ['authentication', 'file-management', 'git-integration']
});
});
// API 根路径信息
app.get('/api', (req, res) => {
res.json({
name: 'WebNote API',
version: '1.0.0',
description: 'VitePress在线编辑API服务器',
endpoints: {
health: '/api/health',
auth: {
login: 'POST /api/auth/login',
me: 'GET /api/auth/me',
logout: 'POST /api/auth/logout'
}
}
});
});
// 错误处理中间件
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({
error: 'Internal Server Error',
message: process.env.NODE_ENV === 'development' ? err.message : 'Something went wrong!'
});
});
// 404 处理
app.use('*', (req, res) => {
res.status(404).json({ error: 'API endpoint not found' });
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`🚀 API服务器启动在端口 ${PORT}`);
console.log(`📋 健康检查: http://43.159.60.248:${PORT}/api/health`);
console.log(`🔐 认证端点: http://43.159.60.248:${PORT}/api/auth`);
console.log(`📖 API文档: http://43.159.60.248:${PORT}/api`);
});
EOF
含义: 更新服务器文件,集成认证路由
5.2 测试集成认证后的服务器
cd ..
npm run dev
含义: 重启服务器并测试
# 健康检查
curl http://localhost:3001/api/health
# 登录
curl -X POST -H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123"}' \
http://localhost:3001/api/auth/login
# 获取用户信息
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:3001/api/auth/me
含义: 确认认证功能正常
6. 提交第二阶段代码
6.1 提交 API 服务器基础代码
git add .
git commit -m "feat: 添加API服务器基础架构和用户认证功能
- 创建Express服务器基础结构
- 实现JWT认证系统
- 添加用户登录/登出API
- 配置安全中间件和跨域处理
- 设置开发环境配置"
含义: 提交第二阶段的所有代码到 Git
现在我们已经完成了第二阶段的 API 服务器基础开发。
第二阶段总结:
- ✅ 创建了完整的 Express API 服务器
- ✅ 实现了 JWT 用户认证系统
- ✅ 配置了安全中间件和跨域处理
- ✅ 设置了开发环境配置
下一步预告: 第三阶段将开发文件操作 API,包括读取、创建、编辑和删除 Markdown 文件的功能。
请测试当前的 API 服务器是否正常运行,然后我们继续下一阶段的开发
第三阶段:文件操作 API 开发
1. 创建文件操作工具类
1.1 创建文件系统工具
cat > api/utils/fileSystem.js << 'EOF'
const fs = require('fs-extra');
const path = require('path');
const DOCS_DIR = path.join(process.env.WORK_DIR, 'docs');
class FileSystemManager {
constructor() {
this.docsDir = DOCS_DIR;
}
// 获取相对于 docs 目录的安全路径
getSafePath(relativePath) {
const fullPath = path.join(this.docsDir, relativePath);
// 确保路径在 docs 目录内(防止路径遍历攻击)
const normalizedPath = path.normalize(fullPath);
if (!normalizedPath.startsWith(this.docsDir)) {
throw new Error('Invalid path: 路径必须在docs目录内');
}
return normalizedPath;
}
// 检查文件是否存在
async exists(relativePath) {
try {
const fullPath = this.getSafePath(relativePath);
return await fs.pathExists(fullPath);
} catch (error) {
return false;
}
}
// 读取文件内容
async readFile(relativePath) {
const fullPath = this.getSafePath(relativePath);
if (!await this.exists(relativePath)) {
throw new Error('文件不存在');
}
return await fs.readFile(fullPath, 'utf8');
}
// 写入文件内容
async writeFile(relativePath, content) {
const fullPath = this.getSafePath(relativePath);
// 确保目录存在
await fs.ensureDir(path.dirname(fullPath));
return await fs.writeFile(fullPath, content, 'utf8');
}
// 创建目录
async createDirectory(relativePath) {
const fullPath = this.getSafePath(relativePath);
return await fs.ensureDir(fullPath);
}
// 删除文件
async deleteFile(relativePath) {
const fullPath = this.getSafePath(relativePath);
if (!await this.exists(relativePath)) {
throw new Error('文件不存在');
}
return await fs.remove(fullPath);
}
// 列出目录内容
async listDirectory(relativePath = '') {
const fullPath = this.getSafePath(relativePath);
if (!await fs.pathExists(fullPath)) {
throw new Error('目录不存在');
}
const items = await fs.readdir(fullPath, { withFileTypes: true });
return items.map(item => ({
name: item.name,
type: item.isDirectory() ? 'directory' : 'file',
path: path.join(relativePath, item.name).replace(/\\/g, '/'),
isMarkdown: item.isFile() && path.extname(item.name) === '.md'
}));
}
// 移动/重命名文件
async moveFile(oldPath, newPath) {
const oldFullPath = this.getSafePath(oldPath);
const newFullPath = this.getSafePath(newPath);
if (!await this.exists(oldPath)) {
throw new Error('源文件不存在');
}
// 确保目标目录存在
await fs.ensureDir(path.dirname(newFullPath));
return await fs.move(oldFullPath, newFullPath);
}
// 获取文件统计信息
async getFileStats(relativePath) {
const fullPath = this.getSafePath(relativePath);
if (!await this.exists(relativePath)) {
throw new Error('文件不存在');
}
const stats = await fs.stat(fullPath);
return {
size: stats.size,
created: stats.birthtime,
modified: stats.mtime,
isDirectory: stats.isDirectory(),
isFile: stats.isFile()
};
}
}
module.exports = new FileSystemManager();
EOF
含义: 创建文件系统管理工具,提供安全的文件操作功能
1.2 创建 Git 操作工具
cat > api/utils/gitManager.js << 'EOF'
const simpleGit = require('simple-git');
const path = require('path');
class GitManager {
constructor() {
this.workDir = process.env.WORK_DIR;
this.git = simpleGit(this.workDir);
}
// 获取当前状态
async getStatus() {
try {
return await this.git.status();
} catch (error) {
console.error('Git status error:', error);
throw new Error('无法获取Git状态');
}
}
// 添加文件到暂存区
async addFile(filePath) {
try {
return await this.git.add(filePath);
} catch (error) {
console.error('Git add error:', error);
throw new Error('无法添加文件到Git');
}
}
// 提交更改
async commit(message, author = null) {
try {
const options = {};
if (author) {
options['--author'] = `${author.name} <${author.email}>`;
}
return await this.git.commit(message, undefined, options);
} catch (error) {
console.error('Git commit error:', error);
throw new Error('Git提交失败');
}
}
// 获取文件的最后修改信息
async getLastModified(filePath) {
try {
const log = await this.git.log(['-1', '--', filePath]);
if (log.latest) {
return {
hash: log.latest.hash,
date: log.latest.date,
message: log.latest.message,
author: {
name: log.latest.author_name,
email: log.latest.author_email
}
};
}
return null;
} catch (error) {
console.error('Git log error:', error);
return null;
}
}
// 检查文件是否有未提交的更改
async hasUncommittedChanges(filePath) {
try {
const status = await this.getStatus();
const relativePath = path.relative(this.workDir, filePath);
return status.files.some(file =>
file.path === relativePath || file.path === filePath
);
} catch (error) {
console.error('Git changes check error:', error);
return false;
}
}
// 获取文件的版本历史
async getFileHistory(filePath, limit = 10) {
try {
const log = await this.git.log([`-${limit}`, '--', filePath]);
return log.all.map(commit => ({
hash: commit.hash,
date: commit.date,
message: commit.message,
author: {
name: commit.author_name,
email: commit.author_email
}
}));
} catch (error) {
console.error('Git history error:', error);
return [];
}
}
// 创建分支
async createBranch(branchName) {
try {
return await this.git.checkoutLocalBranch(branchName);
} catch (error) {
console.error('Git branch creation error:', error);
throw new Error('无法创建分支');
}
}
// 切换分支
async switchBranch(branchName) {
try {
return await this.git.checkout(branchName);
} catch (error) {
console.error('Git checkout error:', error);
throw new Error('无法切换分支');
}
}
}
module.exports = new GitManager();
EOF
含义: 创建 Git 操作管理工具,用于版本控制功能
2. 创建文件操作 API 路由
2.1 创建文件管理 API
cat > api/routes/files.js << 'EOF'
const express = require('express');
const fileSystem = require('../utils/fileSystem');
const gitManager = require('../utils/gitManager');
const { authenticateToken, requireEditPermission } = require('../middleware/auth');
const autoPush = require('../utils/autoPush');
const router = express.Router();
// 获取目录结构
router.get('/tree', authenticateToken, async (req, res) => {
try {
const { path: requestPath = '' } = req.query;
const items = await fileSystem.listDirectory(requestPath);
res.json({
success: true,
path: requestPath,
items
});
} catch (error) {
console.error('List directory error:', error);
res.status(500).json({ error: '无法读取目录' });
}
});
// 读取文件内容
router.get('/content', authenticateToken, async (req, res) => {
try {
const { path: filePath } = req.query;
if (!filePath) {
return res.status(400).json({ error: '文件路径是必需的' });
}
const content = await fileSystem.readFile(filePath);
const stats = await fileSystem.getFileStats(filePath);
const lastModified = await gitManager.getLastModified(filePath);
const hasChanges = await gitManager.hasUncommittedChanges(filePath);
res.json({
success: true,
path: filePath,
content,
stats,
lastModified,
hasUncommittedChanges: hasChanges
});
} catch (error) {
console.error('Read file error:', error);
res.status(500).json({ error: '无法读取文件内容' });
}
});
// 保存文件内容
router.post('/save', authenticateToken, requireEditPermission, async (req, res) => {
try {
const { path: filePath, content, commitMessage, isDraft = false } = req.body;
if (!filePath || content === undefined) {
return res.status(400).json({ error: '文件路径和内容是必需的' });
}
// 保存文件
await fileSystem.writeFile(filePath, content);
if (!isDraft) {
// 不是草稿,直接提交到Git
await gitManager.addFile(filePath);
const message = commitMessage || `更新文件: ${filePath}`;
const author = {
name: req.user.displayName || req.user.username,
email: `${req.user.username}@webnote.local`
};
await gitManager.commit(message, author);
await autoPush(); // 新增:自动推送到远程仓库
}
res.json({
success: true,
message: isDraft ? '草稿已保存' : '文件已保存并提交',
path: filePath
});
} catch (error) {
console.error('Save file error:', error);
res.status(500).json({ error: '保存文件失败' });
}
});
// 创建新文件
router.post('/create', authenticateToken, requireEditPermission, async (req, res) => {
try {
const { path: filePath, content = '', type = 'file' } = req.body;
if (!filePath) {
return res.status(400).json({ error: '文件路径是必需的' });
}
if (await fileSystem.exists(filePath)) {
return res.status(409).json({ error: '文件已存在' });
}
if (type === 'directory') {
await fileSystem.createDirectory(filePath);
} else {
await fileSystem.writeFile(filePath, content);
}
// 添加到Git并提交
if (type === 'file') {
await gitManager.addFile(filePath);
const message = `创建新文件: ${filePath}`;
const author = {
name: req.user.displayName || req.user.username,
email: `${req.user.username}@webnote.local`
};
await gitManager.commit(message, author);
}
res.json({
success: true,
message: type === 'directory' ? '目录已创建' : '文件已创建',
path: filePath,
type
});
} catch (error) {
console.error('Create file error:', error);
res.status(500).json({ error: '创建文件失败' });
}
});
// 删除文件
router.delete('/delete', authenticateToken, requireEditPermission, async (req, res) => {
try {
const { path: filePath } = req.body;
if (!filePath) {
return res.status(400).json({ error: '文件路径是必需的' });
}
await fileSystem.deleteFile(filePath);
// 提交删除操作到Git
await gitManager.addFile(filePath); // 这会暂存删除操作
const message = `删除文件: ${filePath}`;
const author = {
name: req.user.displayName || req.user.username,
email: `${req.user.username}@webnote.local`
};
await gitManager.commit(message, author);
res.json({
success: true,
message: '文件已删除',
path: filePath
});
} catch (error) {
console.error('Delete file error:', error);
res.status(500).json({ error: '删除文件失败' });
}
});
// 重命名/移动文件
router.post('/move', authenticateToken, requireEditPermission, async (req, res) => {
try {
const { oldPath, newPath } = req.body;
if (!oldPath || !newPath) {
return res.status(400).json({ error: '原路径和新路径都是必需的' });
}
if (await fileSystem.exists(newPath)) {
return res.status(409).json({ error: '目标文件已存在' });
}
await fileSystem.moveFile(oldPath, newPath);
// 提交移动操作到Git
await gitManager.addFile(oldPath);
await gitManager.addFile(newPath);
const message = `移动文件: ${oldPath} -> ${newPath}`;
const author = {
name: req.user.displayName || req.user.username,
email: `${req.user.username}@webnote.local`
};
await gitManager.commit(message, author);
res.json({
success: true,
message: '文件已移动',
oldPath,
newPath
});
} catch (error) {
console.error('Move file error:', error);
res.status(500).json({ error: '移动文件失败' });
}
});
// 获取文件历史记录
router.get('/history', authenticateToken, async (req, res) => {
try {
const { path: filePath, limit = 10 } = req.query;
if (!filePath) {
return res.status(400).json({ error: '文件路径是必需的' });
}
const history = await gitManager.getFileHistory(filePath, parseInt(limit));
res.json({
success: true,
path: filePath,
history
});
} catch (error) {
console.error('Get history error:', error);
res.status(500).json({ error: '获取历史记录失败' });
}
});
module.exports = router;
EOF
含义: 创建完整的文件操作 API,包括读取、写入、创建、删除等功能
3. 创建草稿管理功能
3.1 创建草稿存储工具
cat > api/utils/draftManager.js << 'EOF'
const fs = require('fs-extra');
const path = require('path');
class DraftManager {
constructor() {
this.draftDir = path.join(process.env.WORK_DIR, '.drafts');
this.ensureDraftDir();
}
async ensureDraftDir() {
await fs.ensureDir(this.draftDir);
}
// 生成草稿文件名
getDraftFileName(filePath, userId) {
const relativePath = filePath.replace(/[\/\\]/g, '_');
return `${userId}_${relativePath}_${Date.now()}.draft`;
}
// 保存草稿
async saveDraft(filePath, content, userId, metadata = {}) {
const draftFileName = this.getDraftFileName(filePath, userId);
const draftPath = path.join(this.draftDir, draftFileName);
const draftData = {
originalPath: filePath,
content,
userId,
timestamp: new Date().toISOString(),
metadata
};
await fs.writeJson(draftPath, draftData, { spaces: 2 });
return {
draftId: draftFileName,
path: draftPath,
timestamp: draftData.timestamp
};
}
// 获取用户的草稿列表
async getUserDrafts(userId) {
const files = await fs.readdir(this.draftDir);
const userDrafts = files.filter(file =>
file.startsWith(`${userId}_`) && file.endsWith('.draft')
);
const drafts = [];
for (const file of userDrafts) {
try {
const draftPath = path.join(this.draftDir, file);
const draftData = await fs.readJson(draftPath);
drafts.push({
draftId: file,
originalPath: draftData.originalPath,
timestamp: draftData.timestamp,
metadata: draftData.metadata
});
} catch (error) {
console.error(`Error reading draft ${file}:`, error);
}
}
return drafts.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
}
// 获取草稿内容
async getDraft(draftId) {
const draftPath = path.join(this.draftDir, draftId);
if (!await fs.pathExists(draftPath)) {
throw new Error('草稿不存在');
}
return await fs.readJson(draftPath);
}
// 删除草稿
async deleteDraft(draftId) {
const draftPath = path.join(this.draftDir, draftId);
if (!await fs.pathExists(draftPath)) {
throw new Error('草稿不存在');
}
await fs.remove(draftPath);
}
// 清理过期草稿(7天前的)
async cleanupOldDrafts(daysOld = 7) {
const files = await fs.readdir(this.draftDir);
const cutoffTime = new Date(Date.now() - daysOld * 24 * 60 * 60 * 1000);
for (const file of files) {
if (!file.endsWith('.draft')) continue;
try {
const draftPath = path.join(this.draftDir, file);
const draftData = await fs.readJson(draftPath);
const draftTime = new Date(draftData.timestamp);
if (draftTime < cutoffTime) {
await fs.remove(draftPath);
console.log(`Cleaned up old draft: ${file}`);
}
} catch (error) {
console.error(`Error cleaning draft ${file}:`, error);
}
}
}
}
module.exports = new DraftManager();
EOF
含义: 创建草稿管理工具,支持保存、获取和管理用户草稿
3.2 创建草稿 API 路由
cat > api/routes/drafts.js << 'EOF'
const express = require('express');
const draftManager = require('../utils/draftManager');
const { authenticateToken } = require('../middleware/auth');
const router = express.Router();
// 保存草稿
router.post('/save', authenticateToken, async (req, res) => {
try {
const { path: filePath, content, metadata = {} } = req.body;
if (!filePath || content === undefined) {
return res.status(400).json({ error: '文件路径和内容是必需的' });
}
const draft = await draftManager.saveDraft(
filePath,
content,
req.user.id,
{
...metadata,
author: req.user.displayName || req.user.username
}
);
res.json({
success: true,
message: '草稿已保存',
draft
});
} catch (error) {
console.error('Save draft error:', error);
res.status(500).json({ error: '保存草稿失败' });
}
});
// 获取用户草稿列表
router.get('/list', authenticateToken, async (req, res) => {
try {
const drafts = await draftManager.getUserDrafts(req.user.id);
res.json({
success: true,
drafts
});
} catch (error) {
console.error('Get drafts error:', error);
res.status(500).json({ error: '获取草稿列表失败' });
}
});
// 获取特定草稿内容
router.get('/:draftId', authenticateToken, async (req, res) => {
try {
const { draftId } = req.params;
const draft = await draftManager.getDraft(draftId);
// 验证草稿属于当前用户
if (draft.userId !== req.user.id) {
return res.status(403).json({ error: '无权访问此草稿' });
}
res.json({
success: true,
draft
});
} catch (error) {
console.error('Get draft error:', error);
res.status(500).json({ error: '获取草稿失败' });
}
});
// 删除草稿
router.delete('/:draftId', authenticateToken, async (req, res) => {
try {
const { draftId } = req.params;
// 先验证草稿属于当前用户
const draft = await draftManager.getDraft(draftId);
if (draft.userId !== req.user.id) {
return res.status(403).json({ error: '无权删除此草稿' });
}
await draftManager.deleteDraft(draftId);
res.json({
success: true,
message: '草稿已删除'
});
} catch (error) {
console.error('Delete draft error:', error);
res.status(500).json({ error: '删除草稿失败' });
}
});
module.exports = router;
EOF
含义: 创建草稿相关的 API 路由
4. 更新主服务器整合所有路由
4.1 更新服务器主文件
cat > api/server.js << 'EOF'
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
const http = require('http');
const CollaborationManager = require('./utils/collaboration');
require('dotenv').config();
// 导入路由
const authRoutes = require('./routes/auth');
const fileRoutes = require('./routes/files');
const draftRoutes = require('./routes/drafts');
const app = express();
const server = http.createServer(app);
const PORT = process.env.PORT || 3001;
// 初始化协作管理器
const collaboration = new CollaborationManager(server);
// 基础中间件
app.use(helmet());
app.use(morgan('combined'));
app.use(cors({
origin: [
'http://43.159.60.248:8080',
'http://43.159.60.248:3000',
'http://localhost:3000',
'http://localhost:8080'
],
credentials: true
}));
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
// 将协作管理器添加到请求对象
app.use((req, res, next) => {
req.collaboration = collaboration;
next();
});
// API 路由
app.use('/api/auth', authRoutes);
app.use('/api/files', fileRoutes);
app.use('/api/drafts', draftRoutes);
// 获取文件编辑者信息
app.get('/api/collaboration/file-editors/:filePath', (req, res) => {
const filePath = decodeURIComponent(req.params.filePath);
const editors = collaboration.getFileEditors(filePath);
res.json({ success: true, editors });
});
// 健康检查端点
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
version: '1.0.0',
features: ['authentication', 'file-management', 'git-integration', 'draft-management', 'real-time-collaboration']
});
});
// API 根路径信息
app.get('/api', (req, res) => {
res.json({
name: 'WebNote API',
version: '1.0.0',
description: 'VitePress在线编辑API服务器',
endpoints: {
health: '/api/health',
auth: {
login: 'POST /api/auth/login',
me: 'GET /api/auth/me',
logout: 'POST /api/auth/logout'
},
files: {
tree: 'GET /api/files/tree',
content: 'GET /api/files/content',
save: 'POST /api/files/save',
create: 'POST /api/files/create',
delete: 'DELETE /api/files/delete',
move: 'POST /api/files/move',
history: 'GET /api/files/history'
},
drafts: {
save: 'POST /api/drafts/save',
list: 'GET /api/drafts/list',
get: 'GET /api/drafts/:draftId',
delete: 'DELETE /api/drafts/:draftId'
},
collaboration: {
fileEditors: 'GET /api/collaboration/file-editors/:filePath'
}
}
});
});
// 错误处理中间件
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({
error: 'Internal Server Error',
message: process.env.NODE_ENV === 'development' ? err.message : 'Something went wrong!'
});
});
// 404 处理
app.use('*', (req, res) => {
res.status(404).json({ error: 'API endpoint not found' });
});
server.listen(PORT, '0.0.0.0', () => {
console.log(`🚀 API服务器启动在端口 ${PORT}`);
console.log(`📋 健康检查: http://43.159.60.248:${PORT}/api/health`);
console.log(`🔐 认证端点: http://43.159.60.248:${PORT}/api/auth`);
console.log(`📁 文件管理: http://43.159.60.248:${PORT}/api/files`);
console.log(`📝 草稿管理: http://43.159.60.248:${PORT}/api/drafts`);
console.log(`👥 实时协作: WebSocket 已启用`);
console.log(`📖 API文档: http://43.159.60.248:${PORT}/api`);
});
EOF
含义: 更新主服务器,集成 WebSocket 实时协作功能
4.2 测试集成协作后的服务器
cd ..
npm run dev
含义: 重启服务器并测试
# 健康检查
curl http://localhost:3001/api/health
# 登录
curl -X POST -H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123"}' \
http://localhost:3001/api/auth/login
# 获取用户信息
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:3001/api/auth/me
含义: 确认认证功能正常
5. 提交第三阶段代码
5.1 提交所有文件操作功能
git add .
git commit -m "feat: 实现完整的文件操作和草稿管理API
- 添加文件系统管理工具类
- 实现Git版本控制操作
- 创建文件CRUD操作API
- 添加草稿保存和管理功能
- 支持文件历史记录查询
- 集成所有API路由到主服务器"
含义: 提交第三阶段的所有代码
现在我们已经完成了第三阶段的文件操作 API 开发。
第三阶段总结:
- ✅ 创建了安全的文件系统管理工具
- ✅ 实现了 Git 版本控制集成
- ✅ 开发了完整的文件 CRUD API
- ✅ 添加了草稿管理功能
- ✅ 支持文件历史记录查询
下一步预告: 第四阶段将开发前端界面,包括汉堡菜单、登录界面和 Monaco Editor 集成。
请测试当前的 API 功能是否正常工作,然后我们继续第四阶段的开发
第四阶段:前端界面开发
1. 安装前端依赖
1.1 安装 Monaco Editor 和相关依赖
npm install monaco-editor @monaco-editor/loader axios
含义: 安装 Monaco Editor(VS Code 的编辑器组件)和 HTTP 请求库
1.2 安装 Vue 3 状态管理(如果需要)
npm install pinia
含义: 安装 Pinia 状态管理库(Vue 3 推荐)
2. 创建认证状态管理
2.1 创建认证 store
cat > docs/.vitepress/stores/auth.js << 'EOF'
import { ref, computed } from 'vue'
import axios from 'axios'
// API 基础配置
const API_BASE = 'http://43.159.60.248:3001'
// 创建 axios 实例
const api = axios.create({
baseURL: API_BASE,
timeout: 10000,
withCredentials: true
})
// 状态
const user = ref(null)
const token = ref(localStorage.getItem('webnote_token'))
const isLoading = ref(false)
// 计算属性
const isAuthenticated = computed(() => !!token.value && !!user.value)
const isAdmin = computed(() => user.value?.role === 'admin')
const canEdit = computed(() => ['admin', 'editor'].includes(user.value?.role))
// 设置请求拦截器
api.interceptors.request.use((config) => {
if (token.value) {
config.headers.Authorization = `Bearer ${token.value}`
}
return config
})
// 设置响应拦截器
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// 令牌过期或无效,清除认证状态
logout()
}
return Promise.reject(error)
}
)
// 认证方法
const login = async (username, password) => {
try {
isLoading.value = true
const response = await api.post('/api/auth/login', {
username,
password
})
if (response.data.success) {
token.value = response.data.token
user.value = response.data.user
// 保存到本地存储
localStorage.setItem('webnote_token', token.value)
localStorage.setItem('webnote_user', JSON.stringify(user.value))
return { success: true }
}
} catch (error) {
console.error('Login error:', error)
return {
success: false,
message: error.response?.data?.error || '登录失败'
}
} finally {
isLoading.value = false
}
}
const logout = async () => {
try {
if (token.value) {
await api.post('/api/auth/logout')
}
} catch (error) {
console.error('Logout error:', error)
} finally {
// 清除状态和本地存储
token.value = null
user.value = null
localStorage.removeItem('webnote_token')
localStorage.removeItem('webnote_user')
}
}
const checkAuth = async () => {
if (!token.value) return false
try {
const response = await api.get('/api/auth/me')
if (response.data.success) {
user.value = response.data.user
return true
}
} catch (error) {
console.error('Auth check error:', error)
logout()
}
return false
}
// 初始化:从本地存储恢复用户信息
const initAuth = () => {
const savedUser = localStorage.getItem('webnote_user')
if (savedUser && token.value) {
try {
user.value = JSON.parse(savedUser)
// 验证令牌是否仍然有效
checkAuth()
} catch (error) {
console.error('Error parsing saved user:', error)
logout()
}
}
}
export {
api,
user,
token,
isLoading,
isAuthenticated,
isAdmin,
canEdit,
login,
logout,
checkAuth,
initAuth
}
EOF
含义: 创建认证状态管理,处理用户登录、登出和权限验证
3. 创建汉堡菜单组件
3.1 创建汉堡菜单主组件
cat > docs/.vitepress/components/editor/HamburgerMenu.vue << 'EOF'
<template>
<div class="hamburger-menu">
<!-- 汉堡菜单按钮 -->
<button
class="menu-toggle"
@click="toggleMenu"
:class="{ active: isMenuOpen }"
v-if="showMenuButton"
>
<span></span>
<span></span>
<span></span>
</button>
<!-- 侧边栏菜单 -->
<div
class="sidebar"
:class="{ open: isMenuOpen }"
v-if="showMenuButton"
>
<div class="sidebar-header">
<h3>WebNote 编辑器</h3>
<button class="close-btn" @click="closeMenu">×</button>
</div>
<div class="sidebar-content">
<!-- 用户信息 -->
<div class="user-info" v-if="isAuthenticated">
<div class="user-avatar">
{{ user?.displayName?.[0] || user?.username?.[0] || 'U' }}
</div>
<div class="user-details">
<div class="user-name">{{ user?.displayName || user?.username }}</div>
<div class="user-role">{{ user?.role }}</div>
</div>
</div>
<!-- 登录表单 -->
<div class="login-form" v-if="!isAuthenticated">
<h4>登录</h4>
<form @submit.prevent="handleLogin">
<div class="form-group">
<input
v-model="loginForm.username"
type="text"
placeholder="用户名"
required
/>
</div>
<div class="form-group">
<input
v-model="loginForm.password"
type="password"
placeholder="密码"
required
/>
</div>
<button
type="submit"
class="login-btn"
:disabled="isLoading"
>
{{ isLoading ? '登录中...' : '登录' }}
</button>
</form>
<div class="login-error" v-if="loginError">
{{ loginError }}
</div>
</div>
<!-- 编辑功能菜单 -->
<div class="edit-menu" v-if="isAuthenticated && canEdit">
<h4>编辑功能</h4>
<div class="menu-items">
<button
class="menu-item"
@click="openFileTree"
>
📁 文件管理
</button>
<button
class="menu-item"
@click="createNewFile"
>
📄 新建文件
</button>
<button
class="menu-item"
@click="openDrafts"
>
📝 草稿箱
</button>
<button
class="menu-item"
@click="openCurrentFile"
v-if="currentFile"
>
✏️ 编辑当前文件
</button>
</div>
</div>
<!-- 系统功能 -->
<div class="system-menu">
<h4>系统</h4>
<div class="menu-items">
<button
class="menu-item"
@click="refreshPage"
>
🔄 刷新页面
</button>
<button
class="menu-item"
@click="handleLogout"
v-if="isAuthenticated"
>
🚪 登出
</button>
</div>
</div>
</div>
</div>
<!-- 遮罩层 -->
<div
class="overlay"
v-if="isMenuOpen"
@click="closeMenu"
></div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import {
user,
isAuthenticated,
canEdit,
isLoading,
login,
logout,
initAuth
} from '../../stores/auth.js'
// 响应式数据
const isMenuOpen = ref(false)
const loginForm = ref({
username: '',
password: ''
})
const loginError = ref('')
// 计算属性
const showMenuButton = computed(() => {
// 检查是否在文档页面(可以根据路径判断)
return typeof window !== 'undefined' && window.location.pathname.startsWith('/docs')
})
const currentFile = computed(() => {
// 从当前路径获取文件信息
if (typeof window === 'undefined') return null
const path = window.location.pathname
if (path.endsWith('.html')) {
return path.replace('.html', '.md').replace('/docs/', '')
}
return null
})
// 方法
const toggleMenu = () => {
isMenuOpen.value = !isMenuOpen.value
}
const closeMenu = () => {
isMenuOpen.value = false
}
const handleLogin = async () => {
loginError.value = ''
const result = await login(loginForm.value.username, loginForm.value.password)
if (result.success) {
loginForm.value.username = ''
loginForm.value.password = ''
closeMenu()
} else {
loginError.value = result.message
}
}
const handleLogout = async () => {
await logout()
closeMenu()
}
const openFileTree = () => {
// 打开文件树组件
window.dispatchEvent(new CustomEvent('open-file-tree'))
closeMenu()
}
const createNewFile = () => {
// 打开新建文件对话框
window.dispatchEvent(new CustomEvent('create-new-file'))
closeMenu()
}
const openDrafts = () => {
// 打开草稿箱
window.dispatchEvent(new CustomEvent('open-drafts'))
closeMenu()
}
const openCurrentFile = () => {
if (currentFile.value) {
// 打开当前文件编辑器
window.dispatchEvent(new CustomEvent('edit-file', {
detail: { filePath: currentFile.value }
}))
closeMenu()
}
}
const refreshPage = () => {
window.location.reload()
}
// 生命周期
onMounted(() => {
initAuth()
})
</script>
<style scoped>
.hamburger-menu {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
}
/* 汉堡菜单按钮 */
.menu-toggle {
display: flex;
flex-direction: column;
justify-content: space-around;
width: 40px;
height: 40px;
background: #2d3748;
border: none;
border-radius: 8px;
cursor: pointer;
padding: 8px;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.menu-toggle:hover {
background: #4a5568;
transform: scale(1.05);
}
.menu-toggle span {
width: 100%;
height: 3px;
background: white;
border-radius: 2px;
transition: all 0.3s ease;
}
.menu-toggle.active span:nth-child(1) {
transform: rotate(45deg) translate(8px, 8px);
}
.menu-toggle.active span:nth-child(2) {
opacity: 0;
}
.menu-toggle.active span:nth-child(3) {
transform: rotate(-45deg) translate(8px, -8px);
}
/* 侧边栏 */
.sidebar {
position: fixed;
top: 0;
right: -400px;
width: 350px;
height: 100vh;
background: white;
box-shadow: -4px 0 20px rgba(0,0,0,0.1);
transition: right 0.3s ease;
overflow-y: auto;
border-left: 1px solid #e2e8f0;
}
.sidebar.open {
right: 0;
}
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #e2e8f0;
background: #f7fafc;
}
.sidebar-header h3 {
margin: 0;
color: #2d3748;
font-size: 18px;
}
.close-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #718096;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.close-btn:hover {
background: #e2e8f0;
}
/* 侧边栏内容 */
.sidebar-content {
padding: 20px;
}
/* 用户信息 */
.user-info {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: #edf2f7;
border-radius: 8px;
margin-bottom: 24px;
}
.user-avatar {
width: 40px;
height: 40px;
background: #4299e1;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
text-transform: uppercase;
}
.user-details {
flex: 1;
}
.user-name {
font-weight: 600;
color: #2d3748;
}
.user-role {
font-size: 12px;
color: #718096;
text-transform: capitalize;
}
/* 登录表单 */
.login-form {
margin-bottom: 24px;
}
.login-form h4 {
margin: 0 0 16px 0;
color: #2d3748;
}
.form-group {
margin-bottom: 12px;
}
.form-group input {
width: 100%;
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.2s;
}
.form-group input:focus {
outline: none;
border-color: #4299e1;
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.1);
}
.login-btn {
width: 100%;
padding: 12px;
background: #4299e1;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
}
.login-btn:hover:not(:disabled) {
background: #3182ce;
}
.login-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.login-error {
margin-top: 12px;
padding: 8px;
background: #fed7d7;
color: #c53030;
border-radius: 4px;
font-size: 13px;
}
/* 菜单部分 */
.edit-menu, .system-menu {
margin-bottom: 24px;
}
.edit-menu h4, .system-menu h4 {
margin: 0 0 12px 0;
color: #2d3748;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.menu-items {
display: flex;
flex-direction: column;
gap: 8px;
}
.menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background: none;
border: 1px solid #e2e8f0;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
text-align: left;
font-size: 14px;
color: #4a5568;
}
.menu-item:hover {
background: #f7fafc;
border-color: #cbd5e0;
transform: translateY(-1px);
}
/* 遮罩层 */
.overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 9998;
}
/* 响应式设计 */
@media (max-width: 768px) {
.sidebar {
width: 100vw;
right: -100vw;
}
.hamburger-menu {
top: 10px;
right: 10px;
}
}
</style>
EOF
含义: 创建汉堡菜单组件,包含用户认证、文件管理和编辑功能
4. 创建文件树浏览组件
4.1 创建文件树组件
cat > docs/.vitepress/components/editor/FileTree.vue << 'EOF'
<template>
<div class="file-tree-modal" v-if="isVisible" @click.self="close">
<div class="file-tree-container">
<div class="file-tree-header">
<h3>文件管理</h3>
<button class="close-btn" @click="close">×</button>
</div>
<div class="file-tree-toolbar">
<div class="current-path">
<span class="path-label">当前目录:</span>
<span class="path-value">{{ currentPath || '/' }}</span>
</div>
<div class="toolbar-actions">
<button
class="toolbar-btn"
@click="goBack"
:disabled="currentPath === ''"
>
← 上级
</button>
<button
class="toolbar-btn"
@click="refresh"
>
🔄 刷新
</button>
</div>
</div>
<div class="file-tree-content">
<div class="loading" v-if="isLoading">
加载中...
</div>
<div class="error" v-else-if="error">
{{ error }}
</div>
<div class="file-list" v-else>
<div
class="file-item"
v-for="item in items"
:key="item.path"
@click="handleItemClick(item)"
@dblclick="handleItemDoubleClick(item)"
>
<div class="file-icon">
{{ item.type === 'directory' ? '📁' : (item.isMarkdown ? '📄' : '📋') }}
</div>
<div class="file-name">{{ item.name }}</div>
<div class="file-actions" v-if="canEdit">
<button
class="action-btn"
@click.stop="editItem(item)"
v-if="item.isMarkdown"
title="编辑文件"
>
✏️
</button>
<button
class="action-btn"
@click.stop="deleteItem(item)"
title="删除"
>
🗑️
</button>
</div>
</div>
</div>
</div>
<div class="file-tree-footer" v-if="canEdit">
<button class="create-btn" @click="showCreateDialog = true">
+ 新建
</button>
</div>
</div>
<!-- 新建文件/目录对话框 -->
<div class="create-dialog" v-if="showCreateDialog" @click.self="closeCreateDialog">
<div class="dialog-content">
<h4>创建新项目</h4>
<div class="form-group">
<label>类型:</label>
<select v-model="createForm.type">
<option value="file">文件</option>
<option value="directory">目录</option>
</select>
</div>
<div class="form-group">
<label>名称:</label>
<input
v-model="createForm.name"
type="text"
:placeholder="createForm.type === 'file' ? '例如: new-page.md' : '例如: new-folder'"
@keyup.enter="createItem"
/>
</div>
<div class="dialog-actions">
<button @click="closeCreateDialog">取消</button>
<button @click="createItem" :disabled="!createForm.name">创建</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { api, canEdit } from '../../stores/auth.js'
// 响应式数据
const isVisible = ref(false)
const isLoading = ref(false)
const error = ref('')
const currentPath = ref('')
const items = ref([])
const showCreateDialog = ref(false)
const createForm = ref({
type: 'file',
name: ''
})
// 方法
const show = () => {
isVisible.value = true
loadDirectory()
}
const close = () => {
isVisible.value = false
currentPath.value = ''
items.value = []
}
const loadDirectory = async (path = '') => {
try {
isLoading.value = true
error.value = ''
const response = await api.get('/api/files/tree', {
params: { path }
})
if (response.data.success) {
items.value = response.data.items
currentPath.value = path
}
} catch (err) {
error.value = err.response?.data?.error || '加载目录失败'
} finally {
isLoading.value = false
}
}
const goBack = () => {
const pathParts = currentPath.value.split('/').filter(Boolean)
pathParts.pop()
const parentPath = pathParts.join('/')
loadDirectory(parentPath)
}
const refresh = () => {
loadDirectory(currentPath.value)
}
const handleItemClick = (item) => {
// 单击选中
console.log('Selected:', item.name)
}
const handleItemDoubleClick = (item) => {
if (item.type === 'directory') {
loadDirectory(item.path)
} else if (item.isMarkdown) {
editItem(item)
}
}
const editItem = (item) => {
// 触发编辑文件事件
window.dispatchEvent(new CustomEvent('edit-file', {
detail: { filePath: item.path }
}))
close()
}
const deleteItem = async (item) => {
if (!confirm(`确定要删除 "${item.name}" 吗?`)) return
try {
await api.delete('/api/files/delete', {
data: { path: item.path }
})
// 重新加载当前目录
refresh()
} catch (err) {
alert(err.response?.data?.error || '删除失败')
}
}
const createItem = async () => {
if (!createForm.value.name.trim()) return
try {
const itemPath = currentPath.value
? `${currentPath.value}/${createForm.value.name}`
: createForm.value.name
await api.post('/api/files/create', {
path: itemPath,
type: createForm.value.type,
content: createForm.value.type === 'file' ? '# 新文件\n\n内容...' : undefined
})
closeCreateDialog()
refresh()
} catch (err) {
alert(err.response?.data?.error || '创建失败')
}
}
const closeCreateDialog = () => {
showCreateDialog.value = false
createForm.value = {
type: 'file',
name: ''
}
}
// 事件监听
const handleOpenFileTree = () => {
show()
}
onMounted(() => {
window.addEventListener('open-file-tree', handleOpenFileTree)
})
onUnmounted(() => {
window.removeEventListener('open-file-tree', handleOpenFileTree)
})
</script>
<style scoped>
.file-tree-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
padding: 20px;
}
.file-tree-container {
background: white;
border-radius: 12px;
width: 100%;
max-width: 600px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
}
.file-tree-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #e2e8f0;
background: #f7fafc;
}
.file-tree-header h3 {
margin: 0;
color: #2d3748;
}
.close-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #718096;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.close-btn:hover {
background: #e2e8f0;
}
.file-tree-toolbar {
padding: 16px 20px;
border-bottom: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
align-items: center;
background: #f7fafc;
}
.current-path {
font-size: 14px;
}
.path-label {
color: #718096;
margin-right: 8px;
}
.path-value {
color: #2d3748;
font-family: monospace;
background: #e2e8f0;
padding: 2px 6px;
border-radius: 4px;
}
.toolbar-actions {
display: flex;
gap: 8px;
}
.toolbar-btn {
padding: 6px 12px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
}
.toolbar-btn:hover:not(:disabled) {
background: #e2e8f0;
}
.toolbar-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.file-tree-content {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.loading, .error {
text-align: center;
padding: 40px 20px;
color: #718096;
}
.error {
color: #e53e3e;
}
.file-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.file-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
}
.file-item:hover {
background: #f7fafc;
}
.file-icon {
font-size: 16px;
width: 20px;
text-align: center;
}
.file-name {
flex: 1;
color: #2d3748;
font-size: 14px;
}
.file-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.2s;
}
.file-item:hover .file-actions {
opacity: 1;
}
.action-btn {
background: none;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 4px;
font-size: 12px;
}
.action-btn:hover {
background: #e2e8f0;
}
.file-tree-footer {
padding: 16px 20px;
border-top: 1px solid #e2e8f0;
text-align: center;
}
.create-btn {
padding: 8px 16px;
background: #4299e1;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
.create-btn:hover {
background: #3182ce;
}
/* 创建对话框 */
.create-dialog {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
}
.dialog-content {
background: white;
padding: 24px;
border-radius: 8px;
width: 100%;
max-width: 400px;
margin: 20px;
}
.dialog-content h4 {
margin: 0 0 16px 0;
color: #2d3748;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 4px;
color: #4a5568;
font-size: 14px;
}
.form-group select,
.form-group input {
width: 100%;
padding: 8px 12px;
border: 1px solid #e2e8f0;
border-radius: 4px;
font-size: 14px;
}
.dialog-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 20px;
}
.dialog-actions button {
padding: 8px 16px;
border: 1px solid #e2e8f0;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.dialog-actions button:last-child {
background: #4299e1;
color: white;
border-color: #4299e1;
}
.dialog-actions button:last-child:hover:not(:disabled) {
background: #3182ce;
}
.dialog-actions button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
EOF
含义: 创建文件树浏览组件,支持文件管理和导航
5. 创建 Markdown 编辑器组件
5.1 创建编辑器核心组件
cat > docs/.vitepress/components/editor/MarkdownEditor.vue << 'EOF'
<template>
<div class="markdown-editor-modal" v-if="isVisible" @click.self="handleBackdropClick">
<div class="editor-container">
<div class="editor-header">
<div class="file-info">
<h3>{{ fileName }}</h3>
<div class="file-status">
<span v-if="isDirty" class="dirty-indicator">● 未保存</span>
<span v-if="lastSaved" class="last-saved">最后保存: {{ formatTime(lastSaved) }}</span>
</div>
</div>
<div class="header-actions">
<button
class="action-btn save-btn"
@click="saveDraft"
:disabled="!isDirty"
>
💾 保存草稿
</button>
<button
class="action-btn commit-btn"
@click="commitFile"
:disabled="!isDirty || isLoading"
>
✅ 提交保存
</button>
<button class="action-btn close-btn" @click="handleClose">
✕ 关闭
</button>
</div>
</div>
<div class="editor-toolbar">
<div class="toolbar-group">
<button
class="toolbar-btn"
@click="insertText('# ', '')"
title="标题"
>
H1
</button>
<button
class="toolbar-btn"
@click="insertText('**', '**')"
title="加粗"
>
B
</button>
<button
class="toolbar-btn"
@click="insertText('*', '*')"
title="斜体"
>
I
</button>
<button
class="toolbar-btn"
@click="insertText('`', '`')"
title="代码"
>
`
</button>
<button
class="toolbar-btn"
@click="insertText('[', '](url)')"
title="链接"
>
🔗
</button>
<button
class="toolbar-btn"
@click="insertText('- ', '')"
title="列表"
>
•
</button>
</div>
<div class="toolbar-group">
<button
class="toolbar-btn"
@click="togglePreview"
:class="{ active: showPreview }"
>
{{ showPreview ? '编辑' : '预览' }}
</button>
</div>
</div>
<div class="editor-content">
<div class="editor-pane" v-show="!showPreview">
<div
ref="editorContainer"
class="monaco-editor-container"
></div>
</div>
<div class="preview-pane" v-show="showPreview">
<div
class="preview-content"
v-html="previewHtml"
></div>
</div>
</div>
<div class="editor-footer">
<div class="status-info">
<span>行: {{ cursorPosition.line }}</span>
<span>列: {{ cursorPosition.column }}</span>
<span>字符: {{ characterCount }}</span>
</div>
<div class="commit-info" v-if="fileInfo?.lastModified">
<span>最后修改: {{ fileInfo.lastModified.author.name }} - {{ formatTime(fileInfo.lastModified.date) }}</span>
</div>
</div>
</div>
<!-- 提交对话框 -->
<div class="commit-dialog" v-if="showCommitDialog" @click.self="closeCommitDialog">
<div class="dialog-content">
<h4>提交更改</h4>
<div class="form-group">
<label>提交信息:</label>
<input
v-model="commitMessage"
type="text"
placeholder="描述这次更改..."
@keyup.enter="confirmCommit"
/>
</div>
<div class="dialog-actions">
<button @click="closeCommitDialog">取消</button>
<button @click="confirmCommit" :disabled="!commitMessage.trim() || isLoading">
{{ isLoading ? '提交中...' : '确认提交' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import * as monaco from 'monaco-editor'
import { api } from '../../stores/auth.js'
// 响应式数据
const isVisible = ref(false)
const isLoading = ref(false)
const currentFilePath = ref('')
const originalContent = ref('')
const currentContent = ref('')
const fileInfo = ref(null)
const editor = ref(null)
const editorContainer = ref(null)
const isDirty = ref(false)
const lastSaved = ref(null)
const showPreview = ref(false)
const previewHtml = ref('')
const showCommitDialog = ref(false)
const commitMessage = ref('')
const cursorPosition = ref({ line: 1, column: 1 })
const fileHash = ref('');
// 计算属性
const fileName = computed(() => {
if (!currentFilePath.value) return '新文件'
return currentFilePath.value.split('/').pop() || '未知文件'
})
const characterCount = computed(() => {
return currentContent.value.length
})
// 方法
const show = async (filePath) => {
try {
isVisible.value = true
currentFilePath.value = filePath
isLoading.value = true
// 加载文件内容
const response = await api.get('/api/files/content', {
params: { path: filePath }
})
if (response.data.success) {
originalContent.value = response.data.content
currentContent.value = response.data.content
fileInfo.value = response.data
fileHash.value = response.data.lastModified?.hash || '';
isDirty.value = false
await nextTick()
initializeEditor()
}
} catch (err) {
alert(err.response?.data?.error || '加载文件失败')
close()
} finally {
isLoading.value = false
}
}
const close = () => {
if (editor.value) {
editor.value.dispose()
editor.value = null
}
isVisible.value = false
currentFilePath.value = ''
originalContent.value = ''
currentContent.value = ''
fileInfo.value = null
isDirty.value = false
showPreview.value = false
showCommitDialog.value = false
commitMessage.value = ''
}
const initializeEditor = () => {
if (!editorContainer.value) return
// 配置 Monaco Editor
editor.value = monaco.editor.create(editorContainer.value, {
value: currentContent.value,
language: 'markdown',
theme: 'vs',
automaticLayout: true,
wordWrap: 'on',
minimap: { enabled: false },
fontSize: 14,
lineNumbers: 'on',
renderWhitespace: 'selection',
tabSize: 2,
insertSpaces: true
})
// 监听内容变化
editor.value.onDidChangeModelContent(() => {
const newContent = editor.value.getValue()
currentContent.value = newContent
isDirty.value = newContent !== originalContent.value
updatePreview()
})
// 监听光标位置变化
editor.value.onDidChangeCursorPosition((e) => {
cursorPosition.value = {
line: e.position.lineNumber,
column: e.position.column
}
})
// 初始化预览
updatePreview()
}
const updatePreview = () => {
if (!showPreview.value) return
// 简单的 Markdown 转 HTML(实际应用中应该使用专业的 Markdown 解析器)
let html = currentContent.value
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
.replace(/\*\*(.*)\*\*/gim, '<strong>$1</strong>')
.replace(/\*(.*)\*/gim, '<em>$1</em>')
.replace(/`(.*)`/gim, '<code>$1</code>')
.replace(/\n/gim, '<br>')
previewHtml.value = html
}
const insertText = (before, after) => {
if (!editor.value) return
const selection = editor.value.getSelection()
const selectedText = editor.value.getModel().getValueInRange(selection)
editor.value.executeEdits('', [{
range: selection,
text: before + selectedText + after
}])
editor.value.focus()
}
const togglePreview = () => {
showPreview.value = !showPreview.value
if (showPreview.value) {
updatePreview()
}
}
const saveDraft = async () => {
try {
await api.post('/api/drafts/save', {
path: currentFilePath.value,
content: currentContent.value,
metadata: {
fileName: fileName.value,
timestamp: new Date().toISOString()
}
})
lastSaved.value = new Date()
alert('草稿已保存')
} catch (err) {
alert(err.response?.data?.error || '保存草稿失败')
}
}
const commitFile = () => {
commitMessage.value = `更新文件: ${fileName.value}`;
showCommitDialog.value = true;
}
const confirmCommit = async () => {
if (!commitMessage.value.trim()) return
try {
isLoading.value = true;
await api.post('/api/files/save', {
path: currentFilePath.value,
content: currentContent.value,
commitMessage: commitMessage.value,
isDraft: false,
lastHash: fileHash.value // 新增:提交时带上文件hash
});
originalContent.value = currentContent.value
isDirty.value = false
lastSaved.value = new Date()
closeCommitDialog()
alert('文件已保存并提交')
} catch (err) {
if (err.response?.status === 409) {
alert('冲突检测:文件已被他人修改,请刷新后合并您的更改!');
} else {
alert(err.response?.data?.error || '提交失败');
}
} finally {
isLoading.value = false;
}
}
const closeCommitDialog = () => {
showCommitDialog.value = false
commitMessage.value = ''
}
const handleClose = () => {
if (isDirty.value) {
if (!confirm('有未保存的更改,确定要关闭吗?')) {
return
}
}
close()
}
const handleBackdropClick = (event) => {
if (event.target === event.currentTarget) {
handleClose()
}
}
const formatTime = (dateString) => {
return new Date(dateString).toLocaleString('zh-CN')
}
// 监听预览模式变化
watch(showPreview, (newValue) => {
if (newValue) {
updatePreview()
}
})
// 事件监听
const handleEditFile = (event) => {
show(event.detail.filePath)
}
onMounted(() => {
window.addEventListener('edit-file', handleEditFile)
})
onUnmounted(() => {
window.removeEventListener('edit-file', handleEditFile)
if (editor.value) {
editor.value.dispose()
}
})
</script>
<style scoped>
.markdown-editor-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 10001;
padding: 20px;
}
.editor-container {
background: white;
border-radius: 12px;
width: 100%;
height: 100%;
max-width: 1200px;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #e2e8f0;
background: #f7fafc;
}
.file-info h3 {
margin: 0 0 4px 0;
color: #2d3748;
font-size: 18px;
}
.file-status {
display: flex;
gap: 12px;
font-size: 12px;
}
.dirty-indicator {
color: #e53e3e;
font-weight: bold;
}
.last-saved {
color: #718096;
}
.header-actions {
display: flex;
gap: 8px;
}
.action-btn {
padding: 8px 12px;
border: 1px solid #e2e8f0;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
background: white;
}
.save-btn:hover:not(:disabled) {
background: #edf2f7;
border-color: #cbd5e0;
}
.commit-btn {
background: #4299e1;
color: white;
border-color: #4299e1;
}
.commit-btn:hover:not(:disabled) {
background: #3182ce;
}
.close-btn:hover {
background: #fed7d7;
border-color: #fcb3b3;
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.editor-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
border-bottom: 1px solid #e2e8f0;
background: #f7fafc;
}
.toolbar-group {
display: flex;
gap: 4px;
}
.toolbar-btn {
padding: 6px 10px;
border: 1px solid #e2e8f0;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
background: white;
transition: all 0.2s;
}
.toolbar-btn:hover {
background: #e2e8f0;
}
.toolbar-btn.active {
background: #4299e1;
color: white;
border-color: #4299e1;
}
.editor-content {
flex: 1;
display: flex;
overflow: hidden;
}
.editor-pane, .preview-pane {
flex: 1;
overflow: hidden;
}
.monaco-editor-container {
width: 100%;
height: 100%;
}
.preview-content {
padding: 20px;
height: 100%;
overflow-y: auto;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
}
.preview-content h1, .preview-content h2, .preview-content h3 {
margin-top: 24px;
margin-bottom: 16px;
color: #2d3748;
}
.preview-content h1 {
font-size: 24px;
border-bottom: 1px solid #e2e8f0;
padding-bottom: 8px;
}
.preview-content h2 {
font-size: 20px;
}
.preview-content h3 {
font-size: 16px;
}
.preview-content strong {
font-weight: 600;
}
.preview-content code {
background: #f1f5f9;
padding: 2px 4px;
border-radius: 3px;
font-family: 'Fira Code', Consolas, monospace;
font-size: 85%;
}
.editor-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 20px;
border-top: 1px solid #e2e8f0;
background: #f7fafc;
font-size: 12px;
color: #718096;
}
.status-info {
display: flex;
gap: 16px;
}
/* 提交对话框 */
.commit-dialog {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
}
.dialog-content {
background: white;
padding: 24px;
border-radius: 8px;
width: 100%;
max-width: 400px;
margin: 20px;
}
.dialog-content h4 {
margin: 0 0 16px 0;
color: #2d3748;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 4px;
color: #4a5568;
font-size: 14px;
}
.form-group input {
width: 100%;
padding: 8px 12px;
border: 1px solid #e2e8f0;
border-radius: 4px;
font-size: 14px;
}
.dialog-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 20px;
}
.dialog-actions button {
padding: 8px 16px;
border: 1px solid #e2e8f0;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.dialog-actions button:last-child {
background: #4299e1;
color: white;
border-color: #4299e1;
}
.dialog-actions button:last-child:hover:not(:disabled) {
background: #3182ce;
}
.dialog-actions button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 响应式设计 */
@media (max-width: 768px) {
.markdown-editor-modal {
padding: 10px;
}
.editor-header {
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.header-actions {
width: 100%;
justify-content: flex-end;
}
.action-btn {
font-size: 11px;
padding: 6px 8px;
}
}
</style>
EOF
含义: 创建完整的 Markdown 编辑器,包含 Monaco Editor 集成和预览功能
6. 整合组件到 VitePress
6.1 修改 VitePress 配置以包含组件
cat > docs/.vitepress/theme/index.js << 'EOF'
import DefaultTheme from 'vitepress/theme'
import HamburgerMenu from '../components/editor/HamburgerMenu.vue'
import FileTree from '../components/editor/FileTree.vue'
import MarkdownEditor from '../components/editor/MarkdownEditor.vue'
export default {
...DefaultTheme,
enhanceApp({ app }) {
app.component('HamburgerMenu', HamburgerMenu)
app.component('FileTree', FileTree)
app.component('MarkdownEditor', MarkdownEditor)
}
}
EOF
含义: 注册自定义组件到 VitePress 主题
6.2 在布局中添加编辑器组件
cat > docs/.vitepress/theme/Layout.vue << 'EOF'
<template>
<Layout>
<!-- 汉堡菜单 -->
<HamburgerMenu />
<!-- 文件树 -->
<FileTree />
<!-- Markdown 编辑器 -->
<MarkdownEditor />
</Layout>
</template>
<script setup>
import { Layout } from 'vitepress/theme'
import HamburgerMenu from '../components/editor/HamburgerMenu.vue'
import FileTree from '../components/editor/FileTree.vue'
import MarkdownEditor from '../components/editor/MarkdownEditor.vue'
</script>
EOF
含义: 创建自定义布局,包含所有编辑器组件
7. 测试前端功能
7.1 启动 VitePress 开发服务器
npm run docs:dev
含义: 启动前端开发服务器
7.2 同时启动 API 服务器
# 在另一个终端
cd api
npm run dev
含义: 启动后端 API 服务器
8. 提交第四阶段代码
8.1 提交前端界面代码
git add .
git commit -m "feat: 实现前端编辑界面和组件
- 创建汉堡菜单用户界面
- 实现文件树浏览和管理
- 集成Monaco Editor编辑器
- 添加认证状态管理
- 支持草稿保存和文件提交
- 实现响应式设计和预览功能"
含义: 提交第四阶段的所有前端代码
第四阶段总结:
- ✅ 创建了完整的汉堡菜单界面
- ✅ 实现了用户认证状态管理
- ✅ 开发了文件树浏览组件
- ✅ 集成了 Monaco Editor 编辑器
- ✅ 添加了草稿保存和提交功能
- ✅ 支持 Markdown 预览和工具栏
下一步预告: 第五阶段将实现 Git 集成和冲突检测功能。
请测试当前的前端功能是否正常工作,然后我们继续第五阶段的开发
第五阶段:Git 集成与冲突检测
1. 自动化 Git 提交与推送
1.1 配置自动推送脚本
cat > api/utils/autoPush.js << 'EOF'
const simpleGit = require('simple-git');
const path = require('path');
const git = simpleGit(process.env.WORK_DIR);
async function autoPush() {
try {
await git.fetch();
await git.pull();
await git.push();
console.log('✅ 自动推送到远程仓库成功');
} catch (error) {
console.error('❌ 自动推送失败:', error);
}
}
module.exports = autoPush;
EOF
含义: 创建自动推送工具,提交后自动同步到远程仓库
1.2 在文件保存 API 中集成自动推送
// ...api/routes/files.js 文件保存API...
const autoPush = require('../utils/autoPush');
router.post('/save', authenticateToken, requireEditPermission, async (req, res) => {
// ...existing code...
if (!isDraft) {
// ...existing code...
await gitManager.commit(message, author);
await autoPush(); // 新增:自动推送到远程仓库
}
// ...existing code...
});
含义: 文件提交后自动推送到远程仓库,确保线上与本地同步
2. 冲突检测与处理
2.1 检查文件是否有冲突
// ...api/routes/files.js 文件保存API...
const git = require('simple-git')(process.env.WORK_DIR);
async function checkConflict(filePath, clientHash) {
const log = await git.log(['-1', '--', filePath]);
if (log.latest && clientHash && log.latest.hash !== clientHash) {
return true; // 有冲突
}
return false;
}
router.post('/save', authenticateToken, requireEditPermission, async (req, res) => {
const { path: filePath, content, commitMessage, isDraft = false, lastHash } = req.body;
// ...existing code...
if (!isDraft) {
// 冲突检测
const hasConflict = await checkConflict(filePath, lastHash);
if (hasConflict) {
return res.status(409).json({ error: '文件已被他人修改,请刷新后合并您的更改!' });
}
// ...existing code...
}
// ...existing code...
});
含义: 保存文件时检测 Git 最新版本与客户端版本是否一致,防止覆盖他人更改
3. 前端冲突提示与合并
3.1 编辑器组件增加冲突处理
// ...MarkdownEditor.vue...
const fileHash = ref('');
const show = async (filePath) => {
// ...existing code...
if (response.data.success) {
// ...existing code...
fileHash.value = response.data.lastModified?.hash || '';
// ...existing code...
}
// ...existing code...
}
const commitFile = () => {
commitMessage.value = `更新文件: ${fileName.value}`;
showCommitDialog.value = true;
}
const confirmCommit = async () => {
if (!commitMessage.value.trim()) return
try {
isLoading.value = true;
await api.post('/api/files/save', {
path: currentFilePath.value,
content: currentContent.value,
commitMessage: commitMessage.value,
isDraft: false,
lastHash: fileHash.value // 新增:提交时带上文件hash
});
originalContent.value = currentContent.value
isDirty.value = false
lastSaved.value = new Date()
closeCommitDialog()
alert('文件已保存并提交')
} catch (err) {
if (err.response?.status === 409) {
alert('冲突检测:文件已被他人修改,请刷新后合并您的更改!');
} else {
alert(err.response?.data?.error || '提交失败');
}
} finally {
isLoading.value = false;
}
}
含义: 前端编辑器提交时带上文件版本 hash,后端检测冲突并提示用户
4. 文件历史与版本回溯
4.1 文件历史 API 前端调用
// ...MarkdownEditor.vue...
const fileHistory = ref([]);
const loadHistory = async () => {
if (!currentFilePath.value) return;
try {
const response = await api.get('/api/files/history', {
params: { path: currentFilePath.value, limit: 10 }
});
if (response.data.success) {
fileHistory.value = response.data.history;
}
} catch (err) {
fileHistory.value = [];
}
}
onMounted(() => {
// ...existing code...
loadHistory();
});
含义: 加载文件历史,支持版本回溯和对比
5. 提交第五阶段代码
5.1 提交 Git 集成与冲突检测功能
git add .
git commit -m "feat: 集成Git自动推送与冲突检测
- 文件保存自动推送到远程仓库
- 保存时检测文件版本冲突
- 编辑器前端冲突提示
- 支持文件历史查询和版本回溯"
含义: 提交第五阶段所有代码
5.2 合并到主分支
# 切换到主分支
git checkout main
# 合并功能分支
git merge feature/online-editor
# 推送到远程
git push origin main
含义: 将完整功能合并到主分支
6. 部署到生产环境
6.1 在服务器执行部署
# 连接到服务器
ssh webnote@43.159.60.248
# 进入项目目录
cd /home/webnote/webnote/source/WebNote
# 执行部署
./deploy.sh
含义: 在生产服务器执行自动化部署
6.2 设置定时监控
# 添加到 crontab
(crontab -l 2>/dev/null; echo "0 */6 * * * /home/webnote/webnote/source/WebNote/monitor.sh check") | crontab -
(crontab -l 2>/dev/null; echo "0 2 * * * /home/webnote/webnote/source/WebNote/monitor.sh cleanup") | crontab -
(crontab -l 2>/dev/null; echo "0 3 * * 0 /home/webnote/webnote/source/WebNote/monitor.sh backup") | crontab -
含义: 设置定时任务:每6小时检查服务,每天凌晨2点清理日志,每周日凌晨3点备份数据
🎉 项目完成总结
完整功能清单
✅ 用户认证系统
- JWT 令牌认证
- 角色权限控制(admin/editor)
- 安全的密码加密存储
✅ 文件管理功能
- 可视化文件树浏览
- 文件创建、编辑、删除、移动
- 安全路径验证和权限控制
✅ 在线编辑器
- Monaco Editor(VS Code 级别)
- Markdown 语法高亮
- 实时预览功能
- 工具栏快捷操作
✅ 版本控制集成
- 自动 Git 提交和推送
- 文件历史查询
- 冲突检测和处理
- 提交信息自定义
✅ 草稿系统
- 自动草稿保存
- 用户专属草稿管理
- 防止工作丢失
✅ 实时协作
- WebSocket 多用户同步
- 在线用户显示
- 编辑状态实时更新
- 光标位置和选择同步
✅ 生产部署
- PM2 进程管理
- Nginx 反向代理
- 自动化部署脚本
- 监控和维护工具
技术架构
前端 (VitePress + Vue3)
├── 汉堡菜单界面
├── 文件树浏览器
├── Monaco 编辑器
├── 实时协作面板
└── 认证状态管理
后端 (Express + Node.js)
├── JWT 认证系统
├── 文件操作 API
├── Git 版本控制
├── 草稿管理
├── WebSocket 协作
└── 安全中间件
基础设施
├── PM2 进程管理
├── Nginx 反向代理
├── Git 自动化
└── 监控维护
安全特性
- 🔐 JWT 令牌认证
- 🛡️ 路径遍历攻击防护
- ⚡ API 速率限制
- 🔒 CORS 跨域控制
- 📝 输入验证和清理
性能优化
- 🚀 WebSocket 实时通信
- 💾 本地存储状态管理
- 🔄 自动重连机制
- 📊 进程监控和重启
- 🧹 日志轮转和清理
部署说明
- 开发环境:
npm run docs:dev
+cd api && npm run dev
- 生产部署: 执行
./deploy.sh
一键部署 - 服务监控:
pm2 monit
查看状态 - 日志查看:
pm2 logs webnote-api
用户体验
- 📱 响应式设计,支持移动端
- ⚡ 实时预览和协作
- 💾 自动保存防止丢失
- 🎯 直观的文件管理界面
- 🤝 多用户协作提示
🎊 恭喜!WebNote 在线编辑功能开发完成!
现在您拥有了一个功能完整、安全可靠、支持多用户协作的 VitePress 在线编辑系统。用户可以通过网页直接编辑 Markdown 文件,系统会自动处理版本控制、冲突检测和部署更新。