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 @@
+
+
+
+ 欢迎使用 CTMS
+ 当前角色:{{ auth.user?.role || "未知" }}
+
+
+
+
+
+
+
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 @@
+
+
+
+ CTMS 登录
+
+
+
+
+
+
+
+
+ 登录
+
+
+
+
+
+
+
+
+
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
*