Step F1:前端工程骨架 & 登录鉴权

This commit is contained in:
Cheng Zhou
2025-12-16 20:21:30 +08:00
parent 5eab324c50
commit 78d13d077c
25 changed files with 523 additions and 1 deletions
+7
View File
@@ -0,0 +1,7 @@
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "--port", "5173"]
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CTMS</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"name": "ctms-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"axios": "^1.6.8",
"element-plus": "^2.4.4",
"pinia": "^2.1.7",
"vue": "^3.4.15",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@types/node": "^20.10.5",
"@vitejs/plugin-vue": "^5.0.2",
"typescript": "^5.3.3",
"vite": "^5.0.8",
"vue-tsc": "^1.8.25"
}
}
+4
View File
@@ -0,0 +1,4 @@
<template>
<router-view />
<!-- TODO: 预留聚合接口页面/studies/{id}/overview /subjects/{id}/detail /sites/{id}/summary -->
</template>
+10
View File
@@ -0,0 +1,10 @@
import type { AxiosResponse } from "axios";
import api, { apiGet, apiPost } from "./axios";
import type { UserMeResponse, LoginRequest, LoginResponse } from "../types/api";
export const login = (payload: LoginRequest): Promise<AxiosResponse<LoginResponse>> =>
apiPost<LoginResponse>("/api/v1/auth/login", payload);
export const fetchMe = (): Promise<AxiosResponse<UserMeResponse>> => apiGet<UserMeResponse>("/api/v1/auth/me");
export default api;
+45
View File
@@ -0,0 +1,45 @@
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
import { ElMessage } from "element-plus";
import router from "../router";
import { getToken, clearToken } from "../utils/auth";
import type { ApiError } from "../types/api";
const instance: AxiosInstance = axios.create({
baseURL: "/",
timeout: 15000,
});
instance.interceptors.request.use((config: AxiosRequestConfig) => {
const token = getToken();
if (token) {
config.headers = config.headers || {};
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
instance.interceptors.response.use(
(response: AxiosResponse) => response,
async (error: AxiosError<ApiError>) => {
const status = error.response?.status;
const data = error.response?.data;
if (status === 401) {
clearToken();
ElMessage.error(data?.message || "登录已过期,请重新登录");
router.push("/login");
} else if (data?.message) {
ElMessage.error(data.message);
} else {
ElMessage.error("请求失败,请稍后重试");
}
return Promise.reject(error);
}
);
export const apiGet = <T = unknown>(url: string, config?: AxiosRequestConfig) => instance.get<T>(url, config);
export const apiPost = <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
instance.post<T>(url, data, config);
export const apiPatch = <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
instance.patch<T>(url, data, config);
export default instance;
+66
View File
@@ -0,0 +1,66 @@
<template>
<el-container class="layout">
<el-header class="header">
<div class="logo">CTMS</div>
<div class="user-info">
<span>{{ auth.user?.username }}</span>
<el-tag size="small" class="role">{{ auth.user?.role }}</el-tag>
<el-button size="small" type="danger" @click="onLogout">退出登录</el-button>
</div>
</el-header>
<el-container>
<el-aside width="200px" class="aside">
<el-menu router default-active="/home">
<el-menu-item index="/home">首页</el-menu-item>
</el-menu>
</el-aside>
<el-main>
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { useRouter } from "vue-router";
import { useAuthStore } from "../store/auth";
const auth = useAuthStore();
const router = useRouter();
const onLogout = () => {
auth.logout();
router.push("/login");
};
</script>
<style scoped>
.layout {
min-height: 100vh;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
background: #409eff;
color: #fff;
}
.logo {
font-weight: 600;
font-size: 18px;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
}
.role {
background: #fff;
color: #409eff;
border: none;
}
.aside {
border-right: 1px solid #ebeef5;
}
</style>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
+14
View File
@@ -0,0 +1,14 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import ElementPlus from "element-plus";
import "element-plus/dist/index.css";
import App from "./App.vue";
import router from "./router";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.use(ElementPlus);
app.mount("#app");
+48
View File
@@ -0,0 +1,48 @@
import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
import { useAuthStore } from "../store/auth";
import Layout from "../components/Layout.vue";
import Home from "../views/Home.vue";
import Login from "../views/Login.vue";
const routes: RouteRecordRaw[] = [
{
path: "/login",
name: "Login",
component: Login,
meta: { public: true, title: "登录" },
},
{
path: "/",
component: Layout,
redirect: "/home",
children: [
{
path: "home",
name: "Home",
component: Home,
meta: { title: "首页" },
},
],
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
router.beforeEach((to, _from, next) => {
const auth = useAuthStore();
const token = auth.token;
if (!to.meta.public && !token) {
next({ path: "/login" });
return;
}
if (to.path === "/login" && token) {
next({ path: "/" });
return;
}
next();
});
export default router;
+44
View File
@@ -0,0 +1,44 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { login as apiLogin, fetchMe } from "../api/auth";
import { setToken, clearToken, getToken } from "../utils/auth";
import type { UserInfo } from "../types/api";
export const useAuthStore = defineStore("auth", () => {
const token = ref<string | null>(getToken());
const user = ref<UserInfo | null>(null);
const loading = ref(false);
const login = async (username: string, password: string) => {
loading.value = true;
try {
const { data } = await apiLogin({ username, password });
token.value = data.access_token;
setToken(data.access_token);
await fetchMeAction();
} finally {
loading.value = false;
}
};
const fetchMeAction = async () => {
const { data } = await fetchMe();
user.value = data;
return data;
};
const logout = () => {
token.value = null;
user.value = null;
clearToken();
};
return {
token,
user,
loading,
login,
fetchMe: fetchMeAction,
logout,
};
});
+27
View File
@@ -0,0 +1,27 @@
export interface ApiListResponse<T> {
items: T[];
total: number;
}
export interface ApiError {
code: string;
message: string;
}
export interface LoginRequest {
username: string;
password: string;
}
export interface LoginResponse {
access_token: string;
token_type: string;
}
export interface UserInfo {
id: string;
username: string;
role: string;
}
export type UserMeResponse = UserInfo;
+11
View File
@@ -0,0 +1,11 @@
const TOKEN_KEY = "ctms_token";
export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY);
export const setToken = (token: string): void => {
localStorage.setItem(TOKEN_KEY, token);
};
export const clearToken = (): void => {
localStorage.removeItem(TOKEN_KEY);
};
+6
View File
@@ -0,0 +1,6 @@
// 预留权限工具,可在后续基于 role 与项目内角色实现细粒度控制
export const hasPermission = (requiredRoles: string[], userRole?: string): boolean => {
if (!requiredRoles.length) return true;
if (!userRole) return false;
return requiredRoles.includes(userRole);
};
+27
View File
@@ -0,0 +1,27 @@
<template>
<div class="home">
<el-card>
<h2>欢迎使用 CTMS</h2>
<p>当前角色{{ auth.user?.role || "未知" }}</p>
</el-card>
</div>
</template>
<script setup lang="ts">
import { onMounted } from "vue";
import { useAuthStore } from "../store/auth";
const auth = useAuthStore();
onMounted(() => {
if (!auth.user) {
auth.fetchMe();
}
});
</script>
<style scoped>
.home {
padding: 16px;
}
</style>
+73
View File
@@ -0,0 +1,73 @@
<template>
<div class="login-page">
<el-card class="login-card">
<h2 class="title">CTMS 登录</h2>
<el-form :model="form" ref="formRef" :rules="rules" label-width="80px" @keyup.enter.native="onSubmit">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" autocomplete="username" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="form.password" type="password" autocomplete="current-password" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="onSubmit">登录</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from "vue";
import { useRouter } from "vue-router";
import type { FormInstance, FormRules } from "element-plus";
import { useAuthStore } from "../store/auth";
const auth = useAuthStore();
const router = useRouter();
const formRef = ref<FormInstance>();
const form = reactive({
username: "",
password: "",
});
const rules: FormRules<typeof form> = {
username: [{ required: true, message: "请输入用户名", trigger: "blur" }],
password: [{ required: true, message: "请输入密码", trigger: "blur" }],
};
const loading = ref(false);
const onSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (!valid) return;
loading.value = true;
try {
await auth.login(form.username, form.password);
await auth.fetchMe();
router.push("/");
} finally {
loading.value = false;
}
});
};
</script>
<style scoped>
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f5f7fa;
}
.login-card {
width: 360px;
}
.title {
text-align: center;
margin-bottom: 16px;
}
</style>
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
"jsx": "preserve",
"sourceMap": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ESNext", "DOM"],
"types": ["node", "vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
server: {
host: true,
port: 5173,
proxy: {
"/api": {
target: "http://backend:8000",
changeOrigin: true,
},
},
},
});