AI 生成的 Markdown 无法直接分享怎么办?写一个 Markdown to 在线 HTML 链接转换器

本项目的初衷是为了解决我们公司 AI 小组生成的 Markdown 文件共享问题。通常,AI 产生的内容是 Markdown 格式,但分享给他人查看时,需要一个可直接访问的 HTML 页面链接。这个项目正是为此需求而生,它允许用户上传 Markdown 文件,系统将自动转换为美观的 HTML 页面,并提供唯一的访问链接。

Markdown to 在线 HTML 链接转换器,本篇文章你将学会:

  • 使用 VitePress 构建 Markdown 到 HTML 的转换服务,实现优雅的渲染效果
  • 通过 Koa 搭建文件上传和 API 接口服务,处理文件存储和路由转发
  • 利用 Docker + GitHub Actions 自动化部署到腾讯云服务器上,并实现域名访问
  • 配置 Nginx 作为反向代理,实现跨域和安全配置
  • 设计前端单页面应用,提供良好的用户体验

体验地址

可以先行下载代码后阅读,觉得还行的话希望能给仓库点个 star。下面开始动手吧!

项目初始化与环境准备

  • 首先,我们需要安装 Node.js (推荐 v16 以上版本)
  • 安装依赖并初始化项目
bash 复制代码
# 创建项目目录
$ mkdir mdToHtml
$ cd mdToHtml

# 初始化npm项目
$ npm init -y

# 安装核心依赖
$ yarn add vitepress koa koa-router @koa/multer koa-static

项目结构设计

我们的项目结构如下:

csharp 复制代码
├── src/
│   ├── server/         # 服务器代码
│   │   ├── index.js    # Koa服务器主文件
│   │   └── public/     # 静态资源文件
│   └── content/        # 内容相关资源
├── docs/               # VitePress文档目录
│   ├── .vitepress/     # VitePress配置
│   │   ├── config.js   # 主配置文件
│   │   └── theme/      # 自定义主题
│   └── *.md            # Markdown文件
├── simple-uploader/    # 简单的上传界面
│   └── mdUpload.html   # 独立的上传页面
├── Dockerfile          # Docker构建文件
├── nginx.conf          # Nginx配置示例
├── package.json        # 项目依赖
└── README.md           # 项目说明

接下来,让我们修改 package.json 文件,添加必要的脚本命令:

json 复制代码
{
  "name": "mdtohtml",
  "version": "1.0.0",
  "description": "Convert markdown files to HTML with VitePress",
  "main": "src/server/index.js",
  "scripts": {
    "dev": "node src/server/index.js",
    "build": "vitepress build docs",
    "start": "yarn build && node src/server/index.js"
  }
}

这些脚本命令将帮助我们在开发时运行服务器、构建 VitePress 网站并启动生产服务。

VitePress 配置:Markdown 到 HTML 的转换引擎

VitePress 是一个基于 Vue 的静态网站生成器,特别适合文档网站的构建。在我们的项目中,我们利用它将 Markdown 文件转换为美观的 HTML 页面。

首先,创建 VitePress 配置目录和文件:

bash 复制代码
$ mkdir -p docs/.vitepress
$ touch docs/.vitepress/config.js
$ mkdir -p docs/.vitepress/theme
$ touch docs/.vitepress/theme/index.js

配置 VitePress

编辑 docs/.vitepress/config.js 文件:

javascript 复制代码
module.exports = {
  title: 'Markdown to HTML',
  description: 'Convert Markdown to HTML',
  base: '/md/',
  head: [
    ['style', {}, `
      /* 隐藏导航栏和标题 */
      .VPNav, 
      .VPNavBar,
      .VPNavBarTitle,
      .VPLocalNav,
      .header-anchor,
      .VPHomeHero,
      .Layout > header,
      .VPSkipLink {
        display: none !important;
      }
      
      /* 移除页面顶部的空白 */
      .VPContent {
        padding-top: 0 !important;
      }
      
      /* 调整内容区域样式 */
      .VPDoc .container {
        max-width: 90% !important;
        padding: 20px !important;
      }
      
      /* 隐藏标题 */
      .vp-doc h1:first-child {
        display: none !important;
      }
      
      /* 覆盖任何固定定位 */
      .fixed {
        position: static !important;
      }
      
      /* 移除页脚 */
      .VPDocFooter {
        display: none !important;
      }
    `]
  ],
  themeConfig: {
    nav: false,
    sidebar: false,
    footer: false,
    docFooter: false,
    outline: false,
    outlineTitle: false,
    lastUpdated: false,
    socialLinks: false,
    search: false,
    darkMode: false,
    aside: false,
    asideLevels: 0
  }
}

这个配置文件做了几件重要的事情:

  1. 设置网站的基本路径为 /md/,这样所有页面都会在这个路径下
  2. 通过内联 CSS 样式隐藏了 VitePress 默认的导航栏、侧边栏等 UI 元素
  3. 调整了内容区域的样式,使其更加适合纯内容展示
  4. 关闭了所有不必要的功能,如导航、搜索、暗黑模式等

创建默认主题

编辑 docs/.vitepress/theme/index.js 文件:

javascript 复制代码
// 导入默认主题
import DefaultTheme from 'vitepress/theme'

// 简单导出默认主题,不做修改
export default DefaultTheme

创建示例主页

创建 docs/index.md 文件作为示例主页:

markdown 复制代码
# Welcome to Markdown to HTML Converter

This is a sample page generated by VitePress. You can upload your own Markdown files to convert them to beautiful HTML pages.

## Features

- Easy to use
- Beautiful design
- Fast conversion
- Mobile friendly

## How to use

1. Go to the homepage
2. Upload your Markdown file
3. Get your unique URL
4. Share with others

构建 Koa 服务器

现在,我们需要一个服务器来处理 Markdown 文件的上传和转换。我们将使用 Koa.js 框架来构建这个服务器,这是一个轻量级的 Node.js Web 框架,它提供了优雅的中间件架构,非常适合构建 API 和服务。

1. 创建基础服务器结构

首先,创建服务器主文件 src/server/index.js,并设置基本的 Koa 应用结构:

javascript 复制代码
const Koa = require('koa');
const Router = require('koa-router');
const multer = require('@koa/multer');
const path = require('path');
const fs = require('fs');
const serve = require('koa-static');
const { execSync } = require('child_process');

const app = new Koa();
const router = new Router();

这里我们导入了几个重要的依赖:

  • koa: 核心框架
  • koa-router: 用于路由管理
  • @koa/multer: 处理文件上传
  • pathfs: Node.js 内置模块,用于文件路径和文件系统操作
  • koa-static: 提供静态文件服务
  • child_process: 用于执行命令行命令(我们将用它来运行 VitePress 构建)

2. 添加错误处理中间件

错误处理是任何应用的重要部分,让我们添加一个错误处理中间件:

javascript 复制代码
// 错误处理中间件
app.use(async (ctx, next) => {
    try {
        await next();
    } catch (err) {
        console.error('服务器错误:', err);
        console.error('错误堆栈:', err.stack);
        ctx.status = err.status || 500;
        ctx.body = {
            success: false,
            error: err.message || '服务器内部错误',
            details: process.env.NODE_ENV === 'development' ? err.stack : undefined
        };
    }
});

这个中间件会捕获所有在后续中间件中发生的错误,记录错误日志,并向客户端返回适当的错误响应。

3. 添加 CORS 中间件

跨域资源共享(CORS)允许前端从不同的域访问我们的 API:

javascript 复制代码
// CORS 中间件
app.use(async (ctx, next) => {
    // 允许所有来源
    ctx.set('Access-Control-Allow-Origin', '*');
    // 允许的 HTTP 方法
    ctx.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, DELETE');
    // 允许的请求头
    ctx.set('Access-Control-Allow-Headers', '*');
    // 预检请求的有效期
    ctx.set('Access-Control-Max-Age', '86400');
    
    // 处理 OPTIONS 请求
    if (ctx.method === 'OPTIONS') {
        ctx.status = 204;
        return;
    }
    
    await next();
});

这个中间件为所有响应添加了 CORS 相关的头信息,使我们的 API 可以被任何域的前端访问。

4. 添加日志中间件

为了更好地调试和监控请求,添加一个简单的日志中间件:

javascript 复制代码
// 日志中间件
app.use(async (ctx, next) => {
    const start = Date.now();
    console.log('=== 新请求开始 ===');
    console.log(`收到请求: ${ctx.method} ${ctx.url}`);
    console.log('请求头:', JSON.stringify(ctx.request.headers, null, 2));
    console.log('请求体:', JSON.stringify(ctx.request.body, null, 2));
    console.log('Content-Type:', ctx.request.headers['content-type']);
    
    await next();
    
    const ms = Date.now() - start;
    console.log(`请求完成: ${ctx.method} ${ctx.url} - ${ms}ms`);
    console.log('响应状态:', ctx.status);
    console.log('响应体:', JSON.stringify(ctx.body, null, 2));
    console.log('=== 请求结束 ===\n');
});

这个中间件会记录每个请求的详情、处理时间和响应内容,帮助我们了解服务器的运行状况。

5. 配置文件上传功能

接下来,配置 Multer 来处理文件上传:

javascript 复制代码
// 配置 multer 存储
const storage = multer.diskStorage({
    destination: function (req, file, cb) {
        const uploadDir = path.join(__dirname, '../../docs');
        console.log('上传目录路径:', uploadDir);
        console.log('目录是否存在:', fs.existsSync(uploadDir));
        console.log('目录权限:', fs.statSync(uploadDir).mode);
        
        if (!fs.existsSync(uploadDir)) {
            console.log('创建上传目录');
            fs.mkdirSync(uploadDir, { recursive: true });
        }
        cb(null, uploadDir);
    },
    filename: function (req, file, cb) {
        // 生成唯一的文件名
        const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
        const filename = uniqueSuffix + path.extname(file.originalname);
        console.log('生成的文件名:', filename);
        cb(null, filename);
    }
});

const upload = multer({ 
    storage: storage,
    fileFilter: (req, file, cb) => {
        if (file.mimetype === 'text/markdown' || file.originalname.endsWith('.md')) {
            cb(null, true);
        } else {
            cb(new Error('只允许上传 Markdown 文件'), false);
        }
    }
});

这段代码做了几件事:

  1. 配置上传文件的存储位置为 docs 目录
  2. 为每个上传的文件生成一个基于时间戳的唯一文件名
  3. 添加文件过滤器,只允许上传 Markdown 文件

6. 实现文件上传处理逻辑

现在,我们需要实现处理文件上传的核心逻辑:

javascript 复制代码
// 处理文件上传的中间件
const handleUpload = async (ctx) => {
    try {
        console.log('=== 文件上传处理开始 ===');
        console.log('Content-Type:', ctx.request.headers['content-type']);
        console.log('请求头:', JSON.stringify(ctx.request.headers, null, 2));
        console.log('请求体:', JSON.stringify(ctx.request.body, null, 2));
        console.log('文件:', ctx.request.file);
        
        if (!ctx.request.file) {
            console.log('没有文件被上传');
            ctx.status = 400;
            ctx.body = { 
                success: false, 
                error: '没有上传文件',
                details: '请确保使用 POST 方法,Content-Type 为 multipart/form-data,并且文件字段名为 "file"'
            };
            return;
        }

        console.log('文件信息:', {
            originalname: ctx.request.file.originalname,
            filename: ctx.request.file.filename,
            path: ctx.request.file.path,
            mimetype: ctx.request.file.mimetype,
            size: ctx.request.file.size
        });

        // 检查文件是否成功保存
        if (!fs.existsSync(ctx.request.file.path)) {
            console.error('文件保存失败:', ctx.request.file.path);
            throw new Error('文件保存失败');
        }

        // 构建相对路径
        const relativePath = path.relative(path.join(__dirname, '../../docs'), ctx.request.file.path);
        const url = `/${relativePath.replace(/\\/g, '/')}`;
        
        // 构建完整URL (不带.md扩展名)
        const pathWithoutExt = url.replace(/\.md$/, '');
        const fullUrl = `${ctx.protocol}://${ctx.host}/md${pathWithoutExt}`;
        
        // 使用VitePress构建
        console.log('开始构建VitePress站点...');
        try {
            execSync('yarn build', { stdio: 'inherit' });
            console.log('VitePress构建完成');
        } catch (error) {
            console.error('VitePress构建失败:', error);
            throw new Error('HTML生成失败');
        }
        
        ctx.body = {
            success: true,
            url: pathWithoutExt,
            fullUrl: fullUrl
        };
    } catch (error) {
        console.error('处理上传时出错:', error);
        ctx.status = 500;
        ctx.body = {
            success: false,
            error: error.message
        };
    }
};

这个处理器完成以下步骤:

  1. 检查是否有文件上传
  2. 验证文件是否成功保存
  3. 构建文件的相对路径和 URL
  4. 执行 VitePress 构建命令,将 Markdown 转换为 HTML
  5. 返回成功响应,包含生成的 URL

7. 设置路由和静态文件服务

最后,设置 API 路由和静态文件服务:

javascript 复制代码
// API 路由
router.post('/api/upload', upload.single('file'), handleUpload);

// 测试路由
router.get('/api/test', (ctx) => {
    console.log('收到测试请求');
    ctx.body = { success: true, message: 'API 服务器正常运行' };
});

// 添加一个简单的测试路由
router.get('/test', (ctx) => {
    console.log('收到简单测试请求');
    ctx.body = { success: true, message: '简单测试路由正常工作' };
});

// 设置静态文件服务
const distPath = path.join(__dirname, '../../docs/.vitepress/dist');
app.use(serve(distPath));

// 使用路由
app.use(router.routes()).use(router.allowedMethods());

const PORT = process.env.PORT || 5555;
app.listen(PORT, () => {
    console.log(`服务器运行在 http://localhost:${PORT}`);
    console.log(`API 端点: http://localhost:${PORT}/api/upload`);
    console.log(`测试端点: http://localhost:${PORT}/api/test`);
    console.log(`静态文件目录: ${distPath}`);
});

这里我们:

  1. 设置了 /api/upload POST 路由处理文件上传
  2. 添加了两个测试路由,方便检查服务器状态
  3. 配置了静态文件服务,提供对 VitePress 生成的 HTML 文件的访问
  4. 启动服务器,监听指定端口

完整的服务器代码

将上述所有部分组合起来,就是完整的 src/server/index.js 文件:

javascript 复制代码
const Koa = require('koa');
const Router = require('koa-router');
const multer = require('@koa/multer');
const path = require('path');
const fs = require('fs');
const serve = require('koa-static');
const { execSync } = require('child_process');

const app = new Koa();
const router = new Router();

// 错误处理中间件
app.use(async (ctx, next) => {
    try {
        await next();
    } catch (err) {
        console.error('服务器错误:', err);
        console.error('错误堆栈:', err.stack);
        ctx.status = err.status || 500;
        ctx.body = {
            success: false,
            error: err.message || '服务器内部错误',
            details: process.env.NODE_ENV === 'development' ? err.stack : undefined
        };
    }
});

// CORS 中间件
app.use(async (ctx, next) => {
    // 允许所有来源
    ctx.set('Access-Control-Allow-Origin', '*');
    // 允许的 HTTP 方法
    ctx.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, DELETE');
    // 允许的请求头
    ctx.set('Access-Control-Allow-Headers', '*');
    // 预检请求的有效期
    ctx.set('Access-Control-Max-Age', '86400');
    
    // 处理 OPTIONS 请求
    if (ctx.method === 'OPTIONS') {
        ctx.status = 204;
        return;
    }
    
    await next();
});

// 日志中间件
app.use(async (ctx, next) => {
    const start = Date.now();
    console.log('=== 新请求开始 ===');
    console.log(`收到请求: ${ctx.method} ${ctx.url}`);
    console.log('请求头:', JSON.stringify(ctx.request.headers, null, 2));
    console.log('请求体:', JSON.stringify(ctx.request.body, null, 2));
    console.log('Content-Type:', ctx.request.headers['content-type']);
    
    await next();
    
    const ms = Date.now() - start;
    console.log(`请求完成: ${ctx.method} ${ctx.url} - ${ms}ms`);
    console.log('响应状态:', ctx.status);
    console.log('响应体:', JSON.stringify(ctx.body, null, 2));
    console.log('=== 请求结束 ===\n');
});

// 配置 multer 存储
const storage = multer.diskStorage({
    destination: function (req, file, cb) {
        const uploadDir = path.join(__dirname, '../../docs');
        console.log('上传目录路径:', uploadDir);
        console.log('目录是否存在:', fs.existsSync(uploadDir));
        console.log('目录权限:', fs.statSync(uploadDir).mode);
        
        if (!fs.existsSync(uploadDir)) {
            console.log('创建上传目录');
            fs.mkdirSync(uploadDir, { recursive: true });
        }
        cb(null, uploadDir);
    },
    filename: function (req, file, cb) {
        // 生成唯一的文件名
        const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
        const filename = uniqueSuffix + path.extname(file.originalname);
        console.log('生成的文件名:', filename);
        cb(null, filename);
    }
});

const upload = multer({ 
    storage: storage,
    fileFilter: (req, file, cb) => {
        if (file.mimetype === 'text/markdown' || file.originalname.endsWith('.md')) {
            cb(null, true);
        } else {
            cb(new Error('只允许上传 Markdown 文件'), false);
        }
    }
});

// 处理文件上传的中间件
const handleUpload = async (ctx) => {
    try {
        console.log('=== 文件上传处理开始 ===');
        console.log('Content-Type:', ctx.request.headers['content-type']);
        console.log('请求头:', JSON.stringify(ctx.request.headers, null, 2));
        console.log('请求体:', JSON.stringify(ctx.request.body, null, 2));
        console.log('文件:', ctx.request.file);
        
        if (!ctx.request.file) {
            console.log('没有文件被上传');
            ctx.status = 400;
            ctx.body = { 
                success: false, 
                error: '没有上传文件',
                details: '请确保使用 POST 方法,Content-Type 为 multipart/form-data,并且文件字段名为 "file"'
            };
            return;
        }

        console.log('文件信息:', {
            originalname: ctx.request.file.originalname,
            filename: ctx.request.file.filename,
            path: ctx.request.file.path,
            mimetype: ctx.request.file.mimetype,
            size: ctx.request.file.size
        });

        // 检查文件是否成功保存
        if (!fs.existsSync(ctx.request.file.path)) {
            console.error('文件保存失败:', ctx.request.file.path);
            throw new Error('文件保存失败');
        }

        // 构建相对路径
        const relativePath = path.relative(path.join(__dirname, '../../docs'), ctx.request.file.path);
        const url = `/${relativePath.replace(/\\/g, '/')}`;
        
        // 构建完整URL (不带.md扩展名)
        const pathWithoutExt = url.replace(/\.md$/, '');
        const fullUrl = `${ctx.protocol}://${ctx.host}/md${pathWithoutExt}`;
        
        // 使用VitePress构建
        console.log('开始构建VitePress站点...');
        try {
            execSync('yarn build', { stdio: 'inherit' });
            console.log('VitePress构建完成');
        } catch (error) {
            console.error('VitePress构建失败:', error);
            throw new Error('HTML生成失败');
        }
        
        ctx.body = {
            success: true,
            url: pathWithoutExt,
            fullUrl: fullUrl
        };
    } catch (error) {
        console.error('处理上传时出错:', error);
        ctx.status = 500;
        ctx.body = {
            success: false,
            error: error.message
        };
    }
};

// API 路由
router.post('/api/upload', upload.single('file'), handleUpload);

// 测试路由
router.get('/api/test', (ctx) => {
    console.log('收到测试请求');
    ctx.body = { success: true, message: 'API 服务器正常运行' };
});

// 添加一个简单的测试路由
router.get('/test', (ctx) => {
    console.log('收到简单测试请求');
    ctx.body = { success: true, message: '简单测试路由正常工作' };
});

// 设置静态文件服务
const distPath = path.join(__dirname, '../../docs/.vitepress/dist');
app.use(serve(distPath));

// 使用路由
app.use(router.routes()).use(router.allowedMethods());

const PORT = process.env.PORT || 5555;
app.listen(PORT, () => {
    console.log(`服务器运行在 http://localhost:${PORT}`);
    console.log(`API 端点: http://localhost:${PORT}/api/upload`);
    console.log(`测试端点: http://localhost:${PORT}/api/test`);
    console.log(`静态文件目录: ${distPath}`);
});

8. 运行服务器并进行测试

现在,我们可以运行服务器并测试它了:

bash 复制代码
# 确保在项目根目录下
$ yarn dev

你应该会看到类似以下的输出:

ruby 复制代码
服务器运行在 http://localhost:5555
API 端点: http://localhost:5555/api/upload
测试端点: http://localhost:5555/api/test
静态文件目录: /path/to/your/project/docs/.vitepress/dist

9. 使用 Apifox 测试接口

Apifox 是一个 API 测试工具,我们可以用它来测试我们的接口。按照以下步骤操作:

  1. 安装 Apifox :如果还没有安装,请从 Apifox 官网 下载并安装

  2. 创建测试项目

    • 打开 Apifox,创建一个新项目
    • 添加一个新的 API 请求
  3. 测试 /api/test 接口

    • 请求方式:GET
    • URL:http://localhost:5555/api/test
    • 点击"发送"按钮
    • 你应该会收到类似以下的响应:
    json 复制代码
    {
      "success": true,
      "message": "API 服务器正常运行"
    }
  4. 测试文件上传接口

    • 请求方式:POST
    • URL:http://localhost:5555/api/upload
    • 请求类型:选择 multipart/form-data
    • 添加一个字段:
      • 名称:file
      • 类型:File
      • 选择一个 Markdown 文件(.md 后缀)
    • 点击"发送"按钮
    • 如果成功,你应该会收到类似以下的响应:
    json 复制代码
    {
      "success": true,
      "url": "/1634567890-123456789",
      "fullUrl": "http://localhost:5555/md/1634567890-123456789"
    }
  5. 验证生成的 HTML

    • 复制返回中的 fullUrl
    • 在浏览器中打开该 URL
    • 你应该能看到你上传的 Markdown 文件已经被转换为 HTML 页面

如果所有测试都成功,恭喜你!你已经成功构建了一个 Markdown 到 HTML 的转换服务。

10. 故障排查

如果你遇到了问题,以下是一些常见问题的解决方法:

  • 错误:找不到 docs/.vitepress/dist 目录

    • 确保你已经执行了 yarn build 命令
    • 检查 VitePress 是否正确安装
  • 上传失败,提示"只允许上传 Markdown 文件"

    • 确保你上传的文件扩展名为 .md
    • 检查文件的 MIME 类型
  • 访问生成的 URL 返回 404

    • 确保 VitePress 构建成功
    • 检查输出的日志中是否有错误信息
    • 验证静态文件服务是否正确配置
  • CORS 错误

    • 如果从其他域访问,确保 CORS 中间件正确配置
    • 检查浏览器控制台中的错误信息

创建前端上传界面

为了让用户能够方便地上传Markdown文件,我们创建一个简单的前端界面。

创建 simple-uploader/mdUpload.html 文件:

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Markdown文件上传</title>
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
  <style>
    body {
      font-family: 'Helvetica Neue', Arial, sans-serif;
      background-color: #f5f7fa;
      margin: 0;
      padding: 0;
      display: flex;
      justify-content: center;
      align-items: center;
      min-height: 100vh;
    }
    .container {
      background-color: #fff;
      border-radius: 10px;
      box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
      padding: 2rem;
      width: 90%;
      max-width: 600px;
    }
    h1 {
      color: #2c3e50;
      text-align: center;
      margin-bottom: 2rem;
      font-weight: 600;
    }
    .upload-area {
      border: 2px dashed #dcdfe6;
      border-radius: 8px;
      padding: 2rem;
      text-align: center;
      margin-bottom: 1.5rem;
      transition: all 0.3s;
      background-color: #f9fafc;
    }
    .upload-area:hover, .upload-area.dragover {
      border-color: #409eff;
      background-color: #ecf5ff;
    }
    .upload-text {
      color: #606266;
      margin-bottom: 1rem;
    }
    .upload-info {
      font-size: 0.9rem;
      color: #909399;
    }
    .btn {
      display: inline-block;
      padding: 0.75rem 1.5rem;
      border-radius: 5px;
      border: none;
      background-color: #409eff;
      color: white;
      font-size: 1rem;
      cursor: pointer;
      transition: all 0.3s;
      margin-top: 1rem;
    }
    .btn:hover {
      background-color: #66b1ff;
    }
    .file-input {
      display: none;
    }
    .selected-file {
      margin-top: 1rem;
      padding: 0.75rem;
      border-radius: 5px;
      background-color: #f0f9eb;
      color: #67c23a;
      display: flex;
      align-items: center;
      justify-content: space-between;
    }
    .progress-container {
      margin-top: 1.5rem;
      height: 8px;
      background-color: #ebeef5;
      border-radius: 4px;
      overflow: hidden;
    }
    .progress-bar {
      height: 100%;
      background-color: #409eff;
      border-radius: 4px;
      transition: width 0.3s;
    }
    .result-container {
      margin-top: 1.5rem;
      padding: 1rem;
      border-radius: 5px;
      background-color: #f0f9eb;
      color: #67c23a;
    }
    .result-url {
      word-break: break-all;
      margin-top: 0.5rem;
      padding: 0.75rem;
      background-color: #f5f7fa;
      border-radius: 4px;
      border: 1px solid #e4e7ed;
    }
    .copy-btn {
      border: none;
      background-color: #67c23a;
      color: white;
      padding: 0.5rem 1rem;
      border-radius: 4px;
      margin-top: 0.5rem;
      cursor: pointer;
      transition: all 0.3s;
    }
    .copy-btn:hover {
      background-color: #85ce61;
    }
    .error-message {
      margin-top: 1.5rem;
      padding: 1rem;
      border-radius: 5px;
      background-color: #fef0f0;
      color: #f56c6c;
    }
  </style>
</head>
<body>
  <div id="app" class="container">
    <h1>Markdown文件上传</h1>
    
    <div 
      class="upload-area"
      :class="{ 'dragover': isDragging }"
      @dragover.prevent="isDragging = true"
      @dragleave.prevent="isDragging = false"
      @drop.prevent="onFileDrop">
      <p class="upload-text">拖拽Markdown文件到此处,或</p>
      <input 
        type="file" 
        class="file-input" 
        ref="fileInput"
        accept=".md,.markdown" 
        @change="onFileSelected">
      <button class="btn" @click="$refs.fileInput.click()">选择文件</button>
      <p class="upload-info">只支持Markdown文件 (.md, .markdown)</p>
    </div>
    
    <div v-if="selectedFile" class="selected-file">
      <span>已选择: {{ selectedFile.name }}</span>
      <button class="btn" @click="uploadWithRetry" :disabled="uploading">上传</button>
    </div>
    
    <div v-if="uploading" class="progress-container">
      <div class="progress-bar" :style="{ width: uploadProgress + '%' }"></div>
    </div>
    
    <div v-if="error" class="error-message">
      {{ error }}
    </div>
    
    <div v-if="uploadResult" class="result-container">
      <h3>上传成功!</h3>
      <p>生成的HTML文件URL:</p>
      <div class="result-url">{{ uploadResult.url }}</div>
      <button class="copy-btn" @click="copyUrl">复制链接</button>
      
      <div v-if="uploadResult.fullUrl" style="margin-top: 1rem;">
        <p>完整URL:</p>
        <div class="result-url">{{ uploadResult.fullUrl }}</div>
        <button class="copy-btn" @click="copyFullUrl">复制完整链接</button>
      </div>
    </div>
  </div>

  <script>
    const { createApp, ref } = Vue;
    
    createApp({
      setup() {
        const isDragging = ref(false);
        const selectedFile = ref(null);
        const uploading = ref(false);
        const uploadProgress = ref(0);
        const error = ref('');
        const uploadResult = ref(null);
        const fileInput = ref(null);
        
        const resetForm = () => {
          selectedFile.value = null;
          uploadProgress.value = 0;
          error.value = '';
        };
        
        const onFileSelected = (event) => {
          const files = event.target.files;
          if (files && files.length > 0) {
            const file = files[0];
            if (file.name.endsWith('.md') || file.name.endsWith('.markdown')) {
              selectedFile.value = file;
              error.value = '';
            } else {
              error.value = '请选择Markdown文件 (.md, .markdown)';
              selectedFile.value = null;
            }
          }
        };
        
        const onFileDrop = (event) => {
          isDragging.value = false;
          const files = event.dataTransfer.files;
          if (files && files.length > 0) {
            const file = files[0];
            if (file.name.endsWith('.md') || file.name.endsWith('.markdown')) {
              selectedFile.value = file;
              error.value = '';
            } else {
              error.value = '请选择Markdown文件 (.md, .markdown)';
              selectedFile.value = null;
            }
          }
        };
        
        const uploadWithRetry = async () => {
          if (!selectedFile.value) {
            error.value = '请先选择文件';
            return;
          }
          
          const apiUrl = '/api/upload';
          const maxRetries = 3;
          let retries = 0;
          
          const doUpload = async () => {
            try {
              uploading.value = true;
              error.value = '';
              
              const formData = new FormData();
              formData.append('file', selectedFile.value);
              
              const response = await axios.post(apiUrl, formData, {
                headers: {
                  'Content-Type': 'multipart/form-data'
                },
                onUploadProgress: (progressEvent) => {
                  const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total);
                  uploadProgress.value = percentCompleted;
                }
              });
              
              if (response.data && response.data.success) {
                uploadResult.value = {
                  url: response.data.url,
                  fullUrl: response.data.fullUrl
                };
                uploading.value = false;
                // 重置选择的文件
                fileInput.value.value = '';
                selectedFile.value = null;
              } else {
                throw new Error('上传失败');
              }
            } catch (err) {
              if (retries < maxRetries) {
                retries++;
                error.value = `上传失败,正在重试 (${retries}/${maxRetries})...`;
                setTimeout(doUpload, 1000);
              } else {
                error.value = `上传失败:${err.message || '未知错误'}`;
                uploading.value = false;
              }
            }
          };
          
          doUpload();
        };
        
        const copyUrl = () => {
          if (uploadResult.value && uploadResult.value.url) {
            navigator.clipboard.writeText(uploadResult.value.url)
              .then(() => {
                alert('URL已复制到剪贴板');
              })
              .catch((err) => {
                console.error('复制失败:', err);
                alert('复制失败,请手动复制');
              });
          }
        };
        
        const copyFullUrl = () => {
          if (uploadResult.value && uploadResult.value.fullUrl) {
            navigator.clipboard.writeText(uploadResult.value.fullUrl)
              .then(() => {
                alert('完整URL已复制到剪贴板');
              })
              .catch((err) => {
                console.error('复制失败:', err);
                alert('复制失败,请手动复制');
              });
          }
        };
        
        return {
          isDragging,
          selectedFile,
          uploading,
          uploadProgress,
          error,
          uploadResult,
          fileInput,
          onFileSelected,
          onFileDrop,
          uploadWithRetry,
          copyUrl,
          copyFullUrl
        };
      }
    }).mount('#app');
  </script>
</body>
</html>

Docker 容器化部署

为了简化部署过程并确保一致性,我们将使用 Docker 容器化我们的应用。

创建 Dockerfile 文件:

dockerfile 复制代码
# 使用 Node.js Alpine 镜像作为基础镜像
FROM node:18-alpine

# 设置工作目录
WORKDIR /app

# 创建 yarn 缓存目录并设置权限
RUN mkdir -p /home/node/.cache/yarn && \
    chown -R node:node /home/node/.cache

# 复制 package.json 和 yarn.lock
COPY package.json yarn.lock ./

# 安装依赖并清理缓存
RUN yarn install && \
    yarn cache clean

# 复制源代码
COPY . .

# 创建必要的目录并设置权限
RUN mkdir -p docs/.vitepress/dist && \
    chmod -R 777 docs && \
    chown -R node:node /app

# 切换到非 root 用户
USER node

# 设置 yarn 缓存目录
ENV YARN_CACHE_FOLDER=/home/node/.cache/yarn

# 暴露端口
EXPOSE 5555

# 启动命令 - 使用 start 命令启动服务
CMD ["yarn", "start"]

我们使用了官方的 node:18-alpine 镜像作为基础,这是一个轻量级的 Alpine Linux 版本,能够显著减小 Docker 镜像的大小。

构建和运行 Docker 镜像

使用以下命令构建 Docker 镜像:

bash 复制代码
$ docker build -t mdtohtml .

构建完成后,可以使用以下命令运行容器:

bash 复制代码
$ docker run -p 5555:5555 mdtohtml

现在,你可以通过访问 http://localhost:5555 来访问应用。

注意:如果你在腾讯云服务器上部署此项目,请确保在腾讯云安全组/防火墙中开放 5555 端口,否则外部将无法访问服务。具体步骤:

  1. 登录腾讯云控制台
  2. 进入"云服务器" > "实例" > 选择你的实例
  3. 点击"安全组" > "配置规则" > "添加规则"
  4. 添加入站规则,协议选择"TCP",端口为"5555"
  5. 保存配置

配置 Nginx 反向代理

在生产环境中,我们通常需要配置 Nginx 作为反向代理,处理 HTTPS 请求、静态资源缓存和路由转发等工作。

配置 nginx :

nginx 复制代码
server {
    listen 80;  # 添加 HTTP 端口监听
    listen 443 ssl;  # 已有的 HTTPS 端口监听
    server_name junfeng530.xyz;  # 你的域名

    ssl_certificate /www/server/panel/vhost/cert/junfeng530.xyz/fullchain.pem;  # 替换为你的证书路径
    ssl_certificate_key /www/server/panel/vhost/cert/junfeng530.xyz/privkey.pem;  # 替换为你的私钥路径

    # 添加访问日志
    access_log /www/wwwlogs/md-access.log;
    error_log /www/wwwlogs/md-error.log debug;

    # 添加 /md/ 路径的代理规则
    location /md/ {
        # 添加调试日志
        add_header X-Debug-Message "Processing /md/ location" always;
        
        # CORS 配置 - 添加更完整的跨域支持
        add_header 'Access-Control-Allow-Origin' '*' always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE' always;
        add_header 'Access-Control-Allow-Headers' '*' always;
        add_header 'Access-Control-Max-Age' '86400' always;
        
        # 处理 OPTIONS 请求
        if ($request_method = 'OPTIONS') {
            add_header 'Access-Control-Allow-Origin' '*' always;
            add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE' always;
            add_header 'Access-Control-Allow-Headers' '*' always;
            add_header 'Access-Control-Max-Age' '86400' always;
            add_header 'Content-Type' 'text/plain charset=UTF-8';
            add_header 'Content-Length' '0';
            return 204;
        }
        
        # 代理到 Docker 容器,检查是否应该使用 5000 端口
        proxy_pass http://127.0.0.1:5555/;  # 使用 5555 端口 (Docker 内部)
        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;
        
        # 添加这些配置以支持文件上传
        client_max_body_size 50M;
        proxy_connect_timeout 300s;
        proxy_send_timeout 300s;
        proxy_read_timeout 300s;
        
        # 禁用缓冲以处理大型请求
        proxy_buffering off;
        
        # 处理 URL 重写,将 /md 前缀移除
        rewrite ^/md/(.*)$ /$1 break;
    }
}

这个配置文件实现了以下功能:

  1. 配置 HTTP 和 HTTPS 监听
  2. 设置 SSL 证书和密钥路径
  3. 配置访问和错误日志
  4. /md/ 路径设置反向代理,将请求转发到我们的 Docker 容器
  5. 添加 CORS 配置,支持跨域请求
  6. 设置适当的文件上传限制和超时时间
  7. 处理 URL 重写,移除 /md 前缀

使用 GitHub Actions 自动化部署到阿里云

为了实现自动化部署,我们将使用 GitHub Actions,这样每当我们推送代码到 GitHub 仓库时,系统就会自动构建 Docker 镜像并部署到腾讯云服务器上。

创建 .github/workflows/deploy.yml 文件:

yaml 复制代码
name: Deploy to Tencent Cloud

on:
  push:
    branches: [ main ]  # 当推送到 main 分支时触发

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2

      - name: Login to Aliyun Container Registry
        uses: docker/login-action@v1
        with:
          registry: registry.cn-shenzhen.aliyuncs.com
          username: ${{ secrets.ALIYUN_USERNAME }}
          password: ${{ secrets.ALIYUN_PASSWORD }}

      - name: Build and push Docker image
        uses: docker/build-push-action@v2
        with:
          context: .
          push: true
          tags: registry.cn-shenzhen.aliyuncs.com/jiang-nest/jiang-md-to-html:latest

      - name: Deploy to Tencent Cloud
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.TENCENT_HOST }}
          username: ${{ secrets.TENCENT_USERNAME }}
          key: ${{ secrets.TENCENT_SSH_KEY }}
          script: |
            # 登录阿里云容器镜像服务
            docker login --username=${{ secrets.ALIYUN_USERNAME }} --password=${{ secrets.ALIYUN_PASSWORD }} registry.cn-shenzhen.aliyuncs.com
            
            # 拉取最新镜像
            docker pull registry.cn-shenzhen.aliyuncs.com/jiang-nest/jiang-md-to-html:latest
            
            # 停止并删除旧容器
            docker stop jiang-md-to-html || true
            docker rm jiang-md-to-html || true
            
            # 运行新容器
            docker run -d \
              --name jiang-md-to-html \
              -p 5555:5555 \
              registry.cn-shenzhen.aliyuncs.com/jiang-nest/jiang-md-to-html:latest

这个工作流文件实现了以下步骤:

  1. 在推送到 main 分支时触发
  2. 检出代码
  3. 登录到阿里云容器镜像服务
  4. 构建并推送 Docker 镜像到阿里云容器镜像仓库
  5. 通过 SSH 连接到腾讯云服务器
  6. 登录阿里云容器镜像服务
  7. 拉取最新的 Docker 镜像
  8. 停止并删除旧的容器
  9. 运行新的容器,将容器的 5555 端口映射到主机的 5555 端口

要使用这个工作流,你需要在 GitHub 仓库的设置中添加以下 Secrets:

  • ALIYUN_USERNAME: 阿里云容器镜像服务的用户名
  • ALIYUN_PASSWORD: 阿里云容器镜像服务的密码
  • TENCENT_HOST: 腾讯云服务器的 IP 地址
  • TENCENT_USERNAME: 腾讯云服务器的 SSH 用户名
  • TENCENT_SSH_KEY: 腾讯云服务器的 SSH 私钥

设置 GitHub Secrets

  1. 进入你的 GitHub 仓库页面
  2. 点击 "Settings" 选项卡
  3. 在左侧菜单中,选择 "Secrets and variables" > "Actions"
  4. 点击 "New repository secret"
  5. 添加上述提到的 Secrets

完成这些设置后,每当你推送代码到 GitHub 仓库的 main 分支时,GitHub Actions 就会自动构建 Docker 镜像并部署到腾讯云服务器上。

总结与拓展

通过本项目,我们实现了以下功能:

  1. 使用 VitePress 将 Markdown 文件转换为在线 HTML 链接
  2. 通过 Koa 构建了文件上传和 API 服务
  3. 设计了简单的前端上传界面
  4. 使用 Docker 容器化应用
  5. 配置 Nginx 反向代理
  6. 使用 GitHub Actions 实现自动化部署到腾讯云,同时使用阿里云容器镜像服务

这个项目可以进一步拓展,例如:

  • 添加用户认证系统,限制谁可以上传文件
  • 实现文件管理功能,允许用户查看和删除已上传的文件
  • 添加自定义主题支持,允许用户选择不同的样式
  • 集成 Markdown 编辑器,允许用户在线编辑 Markdown 文件
  • 添加图片上传功能,支持在 Markdown 中嵌入图片
  • 实现自动备份功能,定期备份上传的文件

希望这个项目能够帮助你更好地理解现代 Web 应用的开发流程,包括前端、后端、Docker 容器化和自动化部署等方面。如果你有任何问题或建议,欢迎在 GitHub 上提出 issue 或 pull request。

参考资料

相关推荐
倔强青铜三几秒前
WXT浏览器插件开发中文教程(25)----加载远程代码
前端·javascript·vue.js
倔强青铜三8 分钟前
WXT浏览器插件开发中文教程(26)----单元测试与E2E测试
前端·javascript·vue.js
Bingo_BIG35 分钟前
uni-app自动升级功能
前端·javascript·uni-app·移动端开发
IT、木易35 分钟前
Vue 中render函数的作用,如何使用它进行更灵活的组件渲染?
前端·javascript·vue.js
倔强青铜三44 分钟前
WXT浏览器插件开发中文教程(24)----ES 模块支持
前端·javascript·vue.js
SuperherRo1 小时前
Web开发-JS应用&WebPack构建&打包Mode&映射DevTool&源码泄漏&识别还原
前端·javascript·webpack·源码泄露·识别还原
跟着汪老师学编程1 小时前
29、web前端开发之CSS3(六)
前端·css·css3
蒜香拿铁1 小时前
前端批量导入方式
前端
小浣熊喜欢揍臭臭1 小时前
webpack配置详解+项目实战
前端·webpack·node.js