Skip to content

55|前端页面实现(上)

后端接口就绪后,前端需要对应的页面:登录页、资产列表页、资产表单页。上半部分实现基础布局、登录功能和资产列表,包括 API 请求封装、表格组件、分页和搜索。

一、API 请求封装

js
// src/api/request.js
const BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";

async function request(path, options = {}) {
  const url = `${BASE_URL}${path}`;
  const config = {
    headers: {
      "Content-Type": "application/json",
      ...options.headers,
    },
    ...options,
  };

  const token = localStorage.getItem("token");
  if (token) {
    config.headers["Authorization"] = `Bearer ${token}`;
  }

  const response = await fetch(url, config);
  const data = await response.json();

  if (!response.ok) {
    throw new Error(data.message || `HTTP ${response.status}`);
  }

  return data;
}

export const api = {
  get: (path) => request(path),
  post: (path, body) => request(path, { method: "POST", body: JSON.stringify(body) }),
  put: (path, body) => request(path, { method: "PUT", body: JSON.stringify(body) }),
  delete: (path) => request(path, { method: "DELETE" }),
};

二、Pinia 用户状态

js
// src/stores/user.js
import { defineStore } from "pinia";
import { ref, computed } from "vue";

export const useUserStore = defineStore("user", () => {
  const token = ref(localStorage.getItem("token") || "");
  const userInfo = ref(null);

  const isLoggedIn = computed(() => !!token.value);
  const isAdmin = computed(() => userInfo.value?.role === "admin");

  function setToken(newToken) {
    token.value = newToken;
    localStorage.setItem("token", newToken);
  }

  function setUserInfo(info) {
    userInfo.value = info;
  }

  function logout() {
    token.value = "";
    userInfo.value = null;
    localStorage.removeItem("token");
  }

  return { token, userInfo, isLoggedIn, isAdmin, setToken, setUserInfo, logout };
});

三、登录页

vue
<!-- src/views/LoginView.vue -->
<template>
  <div class="login-container">
    <h2>运维平台登录</h2>
    <form @submit.prevent="handleLogin">
      <div class="form-item">
        <label>用户名</label>
        <input v-model="form.username" type="text" required>
      </div>
      <div class="form-item">
        <label>密码</label>
        <input v-model="form.password" type="password" required>
      </div>
      <p v-if="error" class="error">{{ error }}</p>
      <button type="submit" :disabled="loading">
        {{ loading ? "登录中..." : "登录" }}
      </button>
    </form>
  </div>
</template>

<script setup>
import { reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { useUserStore } from "../stores/user.js";
import { api } from "../api/request.js";

const router = useRouter();
const userStore = useUserStore();

const form = reactive({ username: "", password: "" });
const loading = ref(false);
const error = ref("");

async function handleLogin() {
  loading.value = true;
  error.value = "";

  try {
    const params = new URLSearchParams();
    params.append("username", form.username);
    params.append("password", form.password);

    const data = await api.post("/login", Object.fromEntries(params));
    userStore.setToken(data.access_token);

    const me = await api.get("/me");
    userStore.setUserInfo(me);

    router.push("/assets");
  } catch (err) {
    error.value = err.message;
  } finally {
    loading.value = false;
  }
}
</script>

<style scoped>
.login-container {
  max-width: 360px;
  margin: 100px auto;
  padding: 24px;
  border: 1px solid #ddd;
  border-radius: 8px;
}
.form-item {
  margin-bottom: 16px;
}
.form-item label {
  display: block;
  margin-bottom: 4px;
}
.form-item input {
  width: 100%;
  padding: 8px;
  box-sizing: border-box;
}
.error {
  color: #ff4d4f;
}
</style>

四、资产列表页

vue
<!-- src/views/AssetList.vue -->
<template>
  <div>
    <div class="toolbar">
      <input v-model="search.status" placeholder="状态筛选" @change="loadData">
      <button @click="$router.push('/assets/new')">新增资产</button>
    </div>

    <table class="data-table">
      <thead>
        <tr>
          <th>主机名</th>
          <th>IP</th>
          <th>状态</th>
          <th>环境</th>
          <th>操作</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="item in items" :key="item.id">
          <td>{{ item.hostname }}</td>
          <td>{{ item.ip }}</td>
          <td><span :class="item.status">{{ item.status }}</span></td>
          <td>{{ item.env }}</td>
          <td>
            <button @click="viewDetail(item.id)">详情</button>
          </td>
        </tr>
      </tbody>
    </table>

    <div class="pagination">
      <button @click="prevPage" :disabled="skip === 0">上一页</button>
      <span>第 {{ page }} 页</span>
      <button @click="nextPage">下一页</button>
    </div>
  </div>
</template>

<script setup>
import { ref, reactive, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { api } from "../api/request.js";

const router = useRouter();
const items = ref([]);
const skip = ref(0);
const limit = 20;
const search = reactive({ status: "" });

const page = computed(() => Math.floor(skip.value / limit) + 1);

async function loadData() {
  const params = new URLSearchParams();
  params.append("skip", skip.value);
  params.append("limit", limit);
  if (search.status) params.append("status", search.status);

  const data = await api.get(`/assets?${params}`);
  items.value = data;
}

function nextPage() {
  skip.value += limit;
  loadData();
}

function prevPage() {
  skip.value = Math.max(0, skip.value - limit);
  loadData();
}

function viewDetail(id) {
  router.push(`/assets/${id}`);
}

onMounted(loadData);
</script>

五、路由守卫

js
// src/router/index.js
import { createRouter, createWebHistory } from "vue-router";
import { useUserStore } from "../stores/user.js";

const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: "/login", component: () => import("../views/LoginView.vue") },
    {
      path: "/assets",
      component: () => import("../views/AssetList.vue"),
      meta: { requiresAuth: true },
    },
  ],
});

router.beforeEach((to, from, next) => {
  const userStore = useUserStore();
  if (to.meta.requiresAuth && !userStore.isLoggedIn) {
    next("/login");
  } else {
    next();
  }
});