Step F1:前端工程骨架 & 登录鉴权
This commit is contained in:
@@ -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"]
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<template>
|
||||
<router-view />
|
||||
<!-- TODO: 预留聚合接口页面:/studies/{id}/overview /subjects/{id}/detail /sites/{id}/summary -->
|
||||
</template>
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
worker_processes 1;
|
||||
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
upstream frontend {
|
||||
server frontend:5173;
|
||||
}
|
||||
|
||||
upstream backend {
|
||||
server backend:8000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://frontend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
1
|
||||
/var/lib/postgresql/data
|
||||
1765875461
|
||||
1765887655
|
||||
5432
|
||||
/var/run/postgresql
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user