Skip to content

前端页面实现

后端接口已经有了,前端要搭页面来调这些接口。前端基础篇(07-09)学了 Vue 的组件、数据绑定、表单、路由——这里把它们组装成真实页面。

一、API 封装

前端 06 篇封装过一个通用的 request 函数,这里直接用。在项目里建 src/api/ 目录:

js
// src/api/client.js
const BASE_URL = "http://localhost:8000/api";

export function getToken() {
  return localStorage.getItem("access_token");
}

export function setToken(token) {
  localStorage.setItem("access_token", token);
}

export function removeToken() {
  localStorage.removeItem("access_token");
}

export async function request(path, options = {}) {
  const token = getToken();

  const response = await fetch(`${BASE_URL}${path}`, {
    ...options,
    headers: {
      "Content-Type": "application/json",
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
      ...options.headers,
    },
  });

  if (response.status === 401) {
    // Token 过期,清除登录状态,跳登录页
    removeToken();
    window.location.href = "/login";
    throw new Error("未登录");
  }

  if (!response.ok) {
    const error = await response.json().catch(() => ({}));
    throw new Error(error.detail || `HTTP ${response.status}`);
  }

  return response.json();
}

跟 06 篇的版本比,加了 401 处理——Token 过期时清掉本地存储、跳登录页。这个函数是所有 API 调用的统一入口,后续每个模块的 API 函数都基于它。

各模块的 API 函数:

js
// src/api/assets.js
import { request } from "./client";

export const getAssets = (params = {}) => {
  const query = new URLSearchParams(params).toString();
  return request(`/assets${query ? "?" + query : ""}`);
};

export const createAsset = (data) =>
  request("/assets", { method: "POST", body: JSON.stringify(data) });

export const deleteAsset = (id) =>
  request(`/assets/${id}`, { method: "DELETE" });

// src/api/auth.js
import { request, setToken } from "./client";

export const login = async (username, password) => {
  const data = await request("/users/login", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: `username=${username}&password=${password}`,
  });
  setToken(data.access_token);
  return data;
};

页面里调 getAssets() 就像调普通函数——request 封装了 Token、错误处理、JSON 解析,页面代码不用管这些。

二、路由配置

js
// src/router/index.js
import { createRouter, createWebHistory } from "vue-router";
import { getToken } from "../api/client";

const routes = [
  { path: "/login", name: "login", component: () => import("../views/LoginView.vue") },
  { path: "/", name: "assets", component: () => import("../views/AssetListView.vue") },
  { path: "/assets/new", name: "asset-new", component: () => import("../views/AssetEditView.vue") },
  { path: "/events", name: "events", component: () => import("../views/EventListView.vue") },
];

const router = createRouter({
  history: createWebHistory(),
  routes,
});

// 导航守卫:没登录的访问受保护页面,跳登录页
router.beforeEach((to) => {
  if (to.name !== "login" && !getToken()) {
    return { name: "login" };
  }
});

export default router;

router.beforeEach 是全局前置守卫——每次路由跳转前检查。没 Token 且不是访问登录页,就跳到登录页。这是前端层面的认证保护(真正的认证在后端,前端这层只是 UX——没登录就不显示页面内容)。

() => import("../views/LoginView.vue") 是路由懒加载——访问到这个路由时才加载组件代码,减小首屏加载体积。

三、登录页

vue
<!-- src/views/LoginView.vue -->
<script setup>
import { ref } from "vue";
import { useRouter } from "vue-router";
import { login } from "../api/auth";

const router = useRouter();
const username = ref("");
const password = ref("");
const errorMsg = ref("");

async function handleLogin() {
  errorMsg.value = "";
  try {
    await login(username.value, password.value);
    router.push("/");  // 登录成功,跳首页
  } catch (err) {
    errorMsg.value = "用户名或密码错误";
  }
}
</script>

<template>
  <div class="login">
    <h2>ops-console 登录</h2>
    <form @submit.prevent="handleLogin">
      <input v-model="username" placeholder="用户名" />
      <input v-model="password" type="password" placeholder="密码" />
      <button type="submit">登录</button>
      <p v-if="errorMsg" class="error">{{ errorMsg }}</p>
    </form>
  </div>
</template>

@submit.prevent 阻止表单默认提交行为(默认提交会刷新页面),改成调 handleLoginv-model 把输入框值绑定到 ref 变量——08 篇学的表单绑定在这里派上用场。

四、资产列表页

vue
<!-- src/views/AssetListView.vue -->
<script setup>
import { ref, onMounted } from "vue";
import { getAssets, deleteAsset } from "../api/assets";

const assets = ref([]);
const loading = ref(false);

async function loadData(env = null) {
  loading.value = true;
  try {
    assets.value = await getAssets(env ? { env } : {});
  } catch (err) {
    alert("加载失败:" + err.message);
  } finally {
    loading.value = false;
  }
}

async function handleDelete(id) {
  if (!confirm("确定删除这台资产?")) return;
  try {
    await deleteAsset(id);
    await loadData();  // 刷新列表
  } catch (err) {
    alert("删除失败:" + err.message);
  }
}

onMounted(() => loadData());  // 页面加载时请求数据
</script>

<template>
  <div class="asset-list">
    <h2>资产管理</h2>

    <div class="toolbar">
      <select @change="(e) => loadData(e.target.value)">
        <option value="">全部环境</option>
        <option value="prod">生产</option>
        <option value="test">测试</option>
        <option value="dev">开发</option>
      </select>
      <router-link to="/assets/new">新增资产</router-link>
    </div>

    <table v-if="!loading">
      <thead>
        <tr>
          <th>主机名</th>
          <th>IP</th>
          <th>角色</th>
          <th>环境</th>
          <th>状态</th>
          <th>操作</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="asset in assets" :key="asset.id">
          <td>{{ asset.hostname }}</td>
          <td>{{ asset.ip }}</td>
          <td>{{ asset.role }}</td>
          <td>{{ asset.env }}</td>
          <td>{{ asset.status }}</td>
          <td>
            <button @click="handleDelete(asset.id)">删除</button>
          </td>
        </tr>
      </tbody>
    </table>
    <p v-else>加载中...</p>
  </div>
</template>

这是 Vue 基础的综合应用——onMounted 触发数据加载(09 篇生命周期)、v-for 渲染列表(07 篇列表渲染)、@click 绑定事件(07 篇事件处理)、v-if/v-else 控制加载状态(07 篇条件渲染)。前端基础篇学的每个知识点,在这个页面里都有对应

五、事件日志页

vue
<!-- src/views/EventListView.vue -->
<script setup>
import { ref, onMounted } from "vue";
import { request } from "../api/client";

const events = ref([]);

onMounted(async () => {
  events.value = await request("/events");
});
</script>

<template>
  <div class="event-list">
    <h2>操作日志</h2>
    <table>
      <thead>
        <tr><th>时间</th><th>用户</th><th>动作</th><th>对象</th><th>详情</th></tr>
      </thead>
      <tbody>
        <tr v-for="event in events" :key="event.id">
          <td>{{ event.created_at }}</td>
          <td>{{ event.user_id }}</td>
          <td>{{ event.action }}</td>
          <td>{{ event.target_type }} #{{ event.target_id }}</td>
          <td>{{ event.detail }}</td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

事件列表页结构跟资产列表页一样——请求数据、v-for 渲染表格。后端接口返回什么字段,前端表格就展示什么字段。

六、根组件

vue
<!-- src/App.vue -->
<script setup>
import { getToken, removeToken } from "./api/client";
import { useRouter } from "vue-router";

const router = useRouter();

function logout() {
  removeToken();
  router.push("/login");
}
</script>

<template>
  <div class="app">
    <nav v-if="getToken()">
      <router-link to="/">资产</router-link>
      <router-link to="/events">日志</router-link>
      <button @click="logout">登出</button>
    </nav>

    <router-view />  <!-- 路由匹配的页面组件渲染在这里 -->
  </div>
</template>

<router-view /> 是路由出口——当前路由匹配的页面组件在这里渲染。导航栏在所有页面都显示(除了登录页,用 v-if="getToken()" 控制)。

到这里前端能跑了:npm run dev 启动 Vite 开发服务器,访问 http://localhost:5173,跳登录页 → 登录 → 看到资产列表 → 新增/删除 → 看操作日志。前端和后端通过 API 连起来了——下一篇专门讲联调时遇到的问题。