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
|
1
|
||||||
/var/lib/postgresql/data
|
/var/lib/postgresql/data
|
||||||
1765875461
|
1765887655
|
||||||
5432
|
5432
|
||||||
/var/run/postgresql
|
/var/run/postgresql
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user