Appearance
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();
}
});