diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 00000000..e902a7cd --- /dev/null +++ b/frontend/Dockerfile @@ -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"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 00000000..db5f5b13 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + CTMS + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 00000000..7a94757b --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 00000000..cb517758 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,4 @@ + diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 00000000..4cbfc072 --- /dev/null +++ b/frontend/src/api/auth.ts @@ -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> => + apiPost("/api/v1/auth/login", payload); + +export const fetchMe = (): Promise> => apiGet("/api/v1/auth/me"); + +export default api; diff --git a/frontend/src/api/axios.ts b/frontend/src/api/axios.ts new file mode 100644 index 00000000..d4a99937 --- /dev/null +++ b/frontend/src/api/axios.ts @@ -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) => { + 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 = (url: string, config?: AxiosRequestConfig) => instance.get(url, config); +export const apiPost = (url: string, data?: unknown, config?: AxiosRequestConfig) => + instance.post(url, data, config); +export const apiPatch = (url: string, data?: unknown, config?: AxiosRequestConfig) => + instance.patch(url, data, config); + +export default instance; diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue new file mode 100644 index 00000000..9e76054a --- /dev/null +++ b/frontend/src/components/Layout.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts new file mode 100644 index 00000000..fc812394 --- /dev/null +++ b/frontend/src/env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module "*.vue" { + import type { DefineComponent } from "vue"; + const component: DefineComponent<{}, {}, any>; + export default component; +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 00000000..18e568a5 --- /dev/null +++ b/frontend/src/main.ts @@ -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"); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 00000000..aaa4168b --- /dev/null +++ b/frontend/src/router/index.ts @@ -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; diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts new file mode 100644 index 00000000..d8ac7c36 --- /dev/null +++ b/frontend/src/store/auth.ts @@ -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(getToken()); + const user = ref(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, + }; +}); diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts new file mode 100644 index 00000000..57d04d66 --- /dev/null +++ b/frontend/src/types/api.ts @@ -0,0 +1,27 @@ +export interface ApiListResponse { + 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; diff --git a/frontend/src/utils/auth.ts b/frontend/src/utils/auth.ts new file mode 100644 index 00000000..dbdf55c6 --- /dev/null +++ b/frontend/src/utils/auth.ts @@ -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); +}; diff --git a/frontend/src/utils/permission.ts b/frontend/src/utils/permission.ts new file mode 100644 index 00000000..b26b303e --- /dev/null +++ b/frontend/src/utils/permission.ts @@ -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); +}; diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue new file mode 100644 index 00000000..23139c72 --- /dev/null +++ b/frontend/src/views/Home.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 00000000..3375ab4e --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 00000000..222d10c3 --- /dev/null +++ b/frontend/tsconfig.json @@ -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" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 00000000..7065ca9a --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "Node", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 00000000..44bdf546 --- /dev/null +++ b/frontend/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, + }, + }, + }, +}); diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 00000000..ff3a3c51 --- /dev/null +++ b/nginx/nginx.conf @@ -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; + } + } +} diff --git a/pg_data/base/16384/pg_internal.init b/pg_data/base/16384/pg_internal.init index 12834ae9..7ba709ab 100644 Binary files a/pg_data/base/16384/pg_internal.init and b/pg_data/base/16384/pg_internal.init differ diff --git a/pg_data/global/pg_control b/pg_data/global/pg_control index 04c4f839..3d19d35b 100644 Binary files a/pg_data/global/pg_control and b/pg_data/global/pg_control differ diff --git a/pg_data/global/pg_internal.init b/pg_data/global/pg_internal.init index 12740877..ac5b356b 100644 Binary files a/pg_data/global/pg_internal.init and b/pg_data/global/pg_internal.init differ diff --git a/pg_data/pg_wal/000000010000000000000001 b/pg_data/pg_wal/000000010000000000000001 index 7307df45..4a2f36e2 100644 Binary files a/pg_data/pg_wal/000000010000000000000001 and b/pg_data/pg_wal/000000010000000000000001 differ diff --git a/pg_data/postmaster.pid b/pg_data/postmaster.pid index 962beffd..46cd6456 100644 --- a/pg_data/postmaster.pid +++ b/pg_data/postmaster.pid @@ -1,6 +1,6 @@ 1 /var/lib/postgresql/data -1765875461 +1765887655 5432 /var/run/postgresql *