Skip to content

vitepress+nodejs

⏱️最后更新: 2025/07/27

前后端项目

前端项目

先在服务器端启动后端项目,然后优化测试前端项目

目录

markdown
.
|-- .gitignore
|-- docs
| |-- .vitepress
| | |-- config.ts
| | `-- theme
|   |       |-- Layout.vue
|   |       |-- components
|   |       |   `-- MyComponent.vue
| | `-- index.mts
|   `-- index.md
|-- package-lock.json
`-- package.json

MyComponent.vue

vue
<template>
  <div class="api-tester">
    <h2>后端接口测试工具</h2>

    <!-- 1. 健康检查接口测试 -->
    <section class="test-section">
      <h3>1. 健康检查接口(/api/health)</h3>
      <button @click="testHealth" :disabled="loading.health">
        {{ loading.health ? "测试中..." : "开始测试" }}
      </button>
      <div
        class="result"
        :class="{
          success: healthResult?.status === '运行正常',
          error: healthError,
        }"
      >
        <div v-if="healthError">
          <p class="error-msg">❌ 测试失败:{{ healthError.message }}</p>
          <p>状态码:{{ healthError.status }}</p>
        </div>
        <div v-else-if="healthResult">
          <p>✅ 测试成功(状态码:200)</p>
          <p>服务状态:{{ healthResult.status }}</p>
          <p>运行时间:{{ healthResult.uptime.toFixed(2) }} 秒</p>
          <p>内存使用:</p>
          <ul>
            <li>RSS:{{ formatBytes(healthResult.memoryUsage.rss) }}</li>
            <li>
              堆总量:{{ formatBytes(healthResult.memoryUsage.heapTotal) }}
            </li>
            <li>
              堆使用:{{ formatBytes(healthResult.memoryUsage.heapUsed) }}
            </li>
            <li>
              外部内存:{{ formatBytes(healthResult.memoryUsage.external) }}
            </li>
          </ul>
          <p>时间戳:{{ formatDate(healthResult.timestamp) }}</p>
        </div>
      </div>
    </section>

    <!-- 2. 个性化问候接口测试 -->
    <section class="test-section">
      <h3>2. 个性化问候接口(/api/greet)</h3>
      <div class="input-group">
        <label>姓名:</label>
        <input
          type="text"
          v-model="greetName"
          placeholder="请输入姓名(可选)"
        />
      </div>
      <button @click="testGreet" :disabled="loading.greet">
        {{ loading.greet ? "测试中..." : "发送问候" }}
      </button>
      <div
        class="result"
        :class="{ success: greetResult?.message, error: greetError }"
      >
        <div v-if="greetError">
          <p class="error-msg">❌ 测试失败:{{ greetError.message }}</p>
        </div>
        <div v-else-if="greetResult">
          <p>✅ 测试成功(状态码:200)</p>
          <p>{{ greetResult.message }}</p>
          <p>时间戳:{{ formatDate(greetResult.timestamp) }}</p>
        </div>
      </div>
    </section>

    <!-- 3. 服务器时间接口测试 -->
    <section class="test-section">
      <h3>3. 服务器时间接口(/api/time)</h3>
      <button @click="testServerTime" :disabled="loading.serverTime">
        {{ loading.serverTime ? "测试中..." : "获取时间" }}
      </button>
      <div
        class="result"
        :class="{
          success: serverTimeResult?.serverTime,
          error: serverTimeError,
        }"
      >
        <div v-if="serverTimeError">
          <p class="error-msg">❌ 测试失败:{{ serverTimeError.message }}</p>
        </div>
        <div v-else-if="serverTimeResult">
          <p>✅ 测试成功(状态码:200)</p>
          <p>服务器时间:{{ formatDate(serverTimeResult.serverTime) }}</p>
          <p>客户端时间:{{ formatDate(new Date()) }}</p>
          <p>
            时间偏差:{{ calculateTimeDiff(serverTimeResult.serverTime) }} 秒
          </p>
          <p>时区:{{ serverTimeResult.timezone }}</p>
        </div>
      </div>
    </section>
  </div>
</template>

<script setup>
import { ref } from "vue";
import axios from "axios";

// 输入值状态
const greetName = ref("");
const proxyTarget = ref("");

// 结果状态
const healthResult = ref(null);
const greetResult = ref(null);
const proxyResult = ref(null);
const serverTimeResult = ref(null);

// 错误状态
const healthError = ref(null);
const greetError = ref(null);
const proxyError = ref(null);
const serverTimeError = ref(null);

// 加载状态(防止重复点击)
const loading = ref({
  health: false,
  greet: false,
  proxy: false,
  serverTime: false,
});

// 基础URL(根据后端部署地址调整)
const BASE_URL = ref("/api");
const createProxyUrl = (path) => {
  return `${BASE_URL.value}${path}`; // 生成的路径是/api/proxy(正确)
};
const testHealth = async () => {
  resetState("/health");
  loading.value.health = true;
  try {
    const url = `${BASE_URL.value}/health`;
    console.log("健康检查请求URL:", url);

    const response = await axios.get(url); // 用axios替代fetch
    healthResult.value = response.data;

    // 验证点检查
    if (response.data.status !== "运行正常") throw new Error("服务状态异常");
    if (response.data.uptime <= 0) throw new Error("运行时间无效");
  } catch (err) {
    healthError.value = {
      message: err.message,
      status: err.response?.status,
      responseText: err.response?.data,
    };
  } finally {
    loading.value.health = false;
  }
};

// 个性化问候接口(testGreet)
const testGreet = async () => {
  resetState("/greet");
  loading.value.greet = true;
  try {
    // 修正:用BASE_URL.value拼接正确URL
    const url = `${BASE_URL.value}/greet`;
    console.log("问候接口请求URL:", url);

    const response = await axios.get(url, {
      params: { name: greetName.value },
    });

    // 验证响应类型(必须是JSON)
    if (response.headers["content-type"]?.indexOf("application/json") === -1) {
      throw new Error("响应类型错误,预期JSON");
    }

    // 验证数据结构(必须包含message)
    if (!response.data || !response.data.message) {
      throw new Error("响应数据缺失message字段");
    }

    greetResult.value = response.data;
    // 验证问候信息是否包含姓名
    if (!response.data.message.includes(greetName.value || "朋友")) {
      throw new Error("问候信息异常");
    }
  } catch (err) {
    greetError.value = {
      message: err.message,
      status: err.response?.status,
      responseText: err.response?.data || "无响应内容", // 新增:显示错误响应内容,方便调试
    };
  } finally {
    loading.value.greet = false;
  }
};

// 服务器时间接口(testServerTime)
const testServerTime = async () => {
  resetState("/serverTime");
  loading.value.serverTime = true;
  try {
    // 修正:用BASE_URL.value拼接正确URL
    const url = `${BASE_URL.value}/time`;
    console.log("服务器时间接口请求URL:", url);

    const response = await axios.get(url);

    // 验证响应类型
    if (response.headers["content-type"]?.indexOf("application/json") === -1) {
      throw new Error("响应类型错误,预期JSON");
    }

    // 验证数据结构(必须包含serverTime和timezone)
    if (
      !response.data ||
      !response.data.serverTime ||
      !response.data.timezone
    ) {
      throw new Error("响应数据缺失关键字段(serverTime/timezone)");
    }

    serverTimeResult.value = response.data;
    // 验证服务器时间格式(ISO 8601)
    if (!isValidIsoString(response.data.serverTime)) {
      throw new Error("服务器时间格式无效");
    }
  } catch (err) {
    serverTimeError.value = {
      message: err.message,
      status: err.response?.status,
      responseText: err.response?.data || "无响应内容",
    };
  } finally {
    loading.value.serverTime = false;
  }
};

/**
 * 重置状态(根据接口类型)
 */
const resetState = (type) => {
  switch (type) {
    case "/health":
      healthResult.value = null;
      healthError.value = null;
      break;
    case "/greet":
      greetResult.value = null;
      greetError.value = null;
      break;
    case "/proxy":
      proxyResult.value = null;
      proxyError.value = null;
      break;
    case "/serverTime":
      serverTimeResult.value = null;
      serverTimeError.value = null;
      break;
  }
};

/**
 * 工具函数:格式化字节(如123456 → 120.56 KB)
 */
const formatBytes = (bytes) => {
  if (bytes === 0) return "0 B";
  const k = 1024;
  const sizes = ["B", "KB", "MB", "GB"];
  const i = Math.floor(Math.log(bytes) / Math.log(k));
  return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
};

/**
 * 工具函数:格式化日期(ISO → 本地时间)
 */
const formatDate = (isoString) => {
  if (!isoString) return "";
  return new Date(isoString).toLocaleString("zh-CN", {
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
    hour: "2-digit",
    minute: "2-digit",
    second: "2-digit",
  });
};

/**
 * 工具函数:计算服务器时间与客户端时间的偏差(秒)
 */
const calculateTimeDiff = (serverTime) => {
  if (!serverTime) return 0;
  const serverTs = new Date(serverTime).getTime();
  const clientTs = new Date().getTime();
  return ((clientTs - serverTs) / 1000).toFixed(2);
};

/**
 * 工具函数:验证ISO 8601格式
 */
const isValidIsoString = (str) => {
  const isoRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
  return isoRegex.test(str);
};
</script>

<style scoped>
.api-tester {
  max-width: 1200px;
  margin: 0 auto;
  padding: 20px;
  font-family: "微软雅黑", sans-serif;
}

.test-section {
  margin-bottom: 40px;
  padding: 20px;
  border: 1px solid #eee;
  border-radius: 8px;
}

.input-group {
  margin-bottom: 15px;
}

.input-group label {
  margin-right: 10px;
}

.input-group input {
  padding: 8px 12px;
  border: 1px solid #ccc;
  border-radius: 4px;
  width: 300px;
}

button {
  padding: 10px 20px;
  background-color: #409eff;
  color: #fff;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  transition: background-color 0.3s;
}

button:hover {
  background-color: #3a8ee6;
}

button:disabled {
  background-color: #ccc;
  cursor: not-allowed;
}

.result {
  margin-top: 20px;
  padding: 15px;
  border-radius: 4px;
  line-height: 1.6;
}

.result.success {
  border: 1px solid #e6f9ed;
  background-color: #f0f9eb;
  color: #389e0d;
}

.result.error {
  border: 1px solid #fff1f0;
  background-color: #fff1f0;
  color: #cf1322;
}

.error-msg {
  font-weight: bold;
  margin-bottom: 10px;
}

.result ul {
  margin: 10px 0 0 20px;
  padding: 0;
}

.result li {
  margin-bottom: 5px;
}
</style>

Layout.vue

vue
<script setup>
import DefaultTheme from "vitepress/theme";
const { Layout } = DefaultTheme;
</script>

<template>
  <div>
    <Layout />
  </div>
</template>

index.mts

ts
// .vitepress/theme/index.ts(1.x 版本)
import type { Theme, EnhanceAppContext } from "vitepress";
import Layout from "./Layout.vue";
import MyComponent from "./components/MyComponent.vue";

export default {
  Layout,

  enhanceApp({ app, router, siteData }: EnhanceAppContext) {
    const components = [{ comp: MyComponent, name: "MyComponent" }];

    components.forEach(({ comp, name }) => {
      // 避免重复注册
      if (!app.component(name)) {
        app.component(name, comp);
      }
    });
  },
} satisfies Theme;

index.md

markdown
<MyComponent />

config.ts

ts
import { defineConfig } from "vitepress";

// https://vitepress.vuejs.org/config/app-configs
export default defineConfig({});

package.json

json
{
  "name": "vitepress-project",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vitepress dev docs",
    "build": "vitepress build docs",
    "serve": "vitepress serve docs"
  },
  "devDependencies": {
    "vitepress": "1.0.0-alpha.28",
    "vue": "3.2.44",
    "axios": "^1.11.0"
  },
  "pnpm": {
    "peerDependencyRules": {
      "ignoreMissing": ["@algolia/client-search"]
    }
  },
  "dependencies": {
    "axios": "^1.11.0"
  }
}

执行命令进行项目打包

Bash
npm run build

将打包后的 dist 拷贝到部署目录中

后端项目

目录

markdown
.
|-- package.json
`-- src
    `-- index.js

index.js

js
const express = require("express");

// 创建Express应用
const app = express();
const PORT = process.env.PORT || 3001;

// 基础路由 - 欢迎信息
app.get("/", (req, res) => {
  res.json({
    message: "欢迎使用简单Node.js服务!",
    endpoints: [
      { path: "/", description: "欢迎页面" },
      { path: "/api/greet", description: "个性化问候" },
      { path: "/api/time", description: "获取服务器时间" },
      { path: "/api/health", description: "服务健康检查" },
    ],
  });
});

// 个性化问候API
app.get("/api/greet", (req, res) => {
  const name = req.query.name || "朋友";
  res.json({
    message: `你好,${name}!`,
    timestamp: new Date().toISOString(),
  });
});

// 服务器时间API
app.get("/api/time", (req, res) => {
  res.json({
    serverTime: new Date().toISOString(),
    timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
  });
});

// 健康检查API
app.get("/api/health", (req, res) => {
  res.json({
    status: "运行正常",
    uptime: process.uptime(),
    memoryUsage: process.memoryUsage(),
    timestamp: new Date().toISOString(),
  });
});

// 启动服务器
app.listen(PORT, () => {
  console.log(`🚀 服务器已启动,运行在 http://localhost:${PORT}`);
  console.log("➤ 按 Ctrl+C 停止服务");
});

// 优雅关闭处理
process.on("SIGINT", () => {
  console.log("\n🛑 正在关闭服务器...");
  process.exit(0);
});

package.json

json
{
  "name": "simple-node-project",
  "version": "1.0.0",
  "description": "一个简单的Node.js项目示例",
  "main": "index.js",
  "scripts": {
    "start": "node index.js",
    "dev": "nodemon index.js"
  },
  "engines": {
    "node": ">=14.0.0"
  },
  "dependencies": {
    "express": "^4.18.3"
  },
  "devDependencies": {
    "nodemon": "^3.1.0"
  }
}

部署准备

目录

markdown
.
|-- backend
| |-- .dockerignore
| |-- Dockerfile.node
| |-- package.json
| `-- src
|       `-- index.js
|-- default.conf // nginx 配置文件
|-- dist // 前端打包的项目
| |-- 404.html
| |-- assets
| |-- hashmap.json
| `-- index.html
|-- docker-compose.yml
|-- dockerfile

default.conf

bash
server {
    listen 80;
    server_name 106.52.78.231;

    # 前端静态文件服务
    location / {
        root /usr/share/nginx/html;
        index index.html;
        try_files $uri $uri/ /index.html;

        # 基础安全头
        add_header X-Frame-Options "SAMEORIGIN";
        add_header X-Content-Type-Options "nosniff";
    }

    # 后端接口代理(关键修正)
    location /api/ {
        proxy_pass http://nodejs:3001; #正确:使用Docker Compose服务名(nodejs)
        proxy_set_header Host $host; #传递Host头(后端需要时)
        proxy_set_header X-Real-IP $remote_addr;# 传递真实IP(可选)

        # CORS配置(允许前端 origin)
        add_header 'Access-Control-Allow-Origin' '$http_origin' always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
        add_header 'Access-Control-Allow-Headers' '*' always;
        add_header 'Access-Control-Allow-Credentials' 'true' always;

        # 处理预检请求(OPTIONS)
        if ($request_method = 'OPTIONS') {
            add_header 'Access-Control-Max-Age' 1728000;
            add_header 'Content-Type' 'text/plain; charset=UTF-8';
            add_header 'Content-Length' 0;
            return 204;
        }
    }
}

dockerfile

bash
FROM nginx:1.9.0
COPY dist/ /usr/share/nginx/html/
COPY default.conf /etc/nginx/conf.d/default.conf

Dockerfile.node

bash
FROM node:18-alpine

# 1. 创建非Root用户(appuser)
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

# 2. 设置工作目录(/app)
WORKDIR /app

# 3. 复制依赖文件(package.json/package-lock.json)
COPY package*.json ./

# 4. 安装生产依赖(使用淘宝源,避免DNS问题)
RUN npm install --omit=dev --registry=https://registry.npmmirror.com

# 5. 复制代码(此时已通过.dockerignore排除冗余文件)
COPY . .

# 6. 改变工作目录所有权(赋予appuser读写权限)
RUN chown -R appuser:appgroup /app

# 7. 切换到非Root用户(安全运行)
USER appuser

# 8. 暴露端口(与docker-compose一致)
EXPOSE 3000

# 9. 启动命令(指向src/index.js)
CMD ["node", "src/index.js"]

docker-compose.yml

yml
# docker-compose.yml(部分)
services:
  vueapp:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "80:80"
    restart: unless-stopped
    depends_on:
      - nodejs # 前端依赖后端启动
    networks:
      - appnet

  nodejs:
    build:
      context: ./backend
      dockerfile: Dockerfile.node
    ports:
      - "3001:3001" # 可选:保留端口映射(方便调试)
    restart: unless-stopped
    networks:
      - appnet # 加入同一网络

networks:
  appnet:
    driver: bridge

启动服务并验证

执行命令

bash
sudo docker-compose up -d

验证服务,启动浏览器

http://ip地址

应如下所示 picture 0