Add 'frontend/' from commit '94f710850fa74ad3aaca5b65318f2fec9e1a2cdf'

git-subtree-dir: frontend
git-subtree-mainline: 3f114b2cc3ad1bfe7399c1c78b54da47a88a3638
git-subtree-split: 94f710850fa74ad3aaca5b65318f2fec9e1a2cdf
This commit is contained in:
carry 2025-02-17 17:45:26 +08:00
commit b705bbfa7d
26 changed files with 5761 additions and 0 deletions

24
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
frontend/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
frontend/README.md Normal file
View File

@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

131
frontend/devdoc.md Normal file
View File

@ -0,0 +1,131 @@
## 项目概述
本项目是一个用户管理系统,前端部分使用 Vue.js 作为主要框架,结合 Vue Router 进行路由管理Pinia 进行状态管理Element Plus 作为 UI 组件库。前端需要实现登录页面和用户管理页面,并根据用户角色控制页面元素的显示与隐藏,特别是对修改和删除按钮的权限控制。
## 技术栈
* Typescript
* Vue.js前端框架用于构建用户界面。
* Vue Router用于管理前端路由。
* Pinia用于状态管理存储用户登录状态、角色信息等。
* Element PlusUI 组件库,提供丰富的 UI 组件。
* Axios用于与后端 API 进行通信。
* Vite构建工具用于快速开发和打包项目。
* npm包管理工具
* pinia-plugin-persistedstate用于状态持久化存储
* jwt-decode用于解析JWT令牌
## 项目结构
```
frontend/
├── src/
│ ├── api/ # API 服务相关
│ ├── components/ # 公共组件
│ ├── router/ # 路由配置
│ ├── store/ # 状态管理
│ ├── views/ # 页面视图
│ ├── App.vue # 根组件
│ ├── main.ts # 入口文件
│ └── style.css # 全局样式
├── index.html # 主页面
├── package.json # 项目依赖
├── vite.config.ts # Vite 配置
└── tsconfig.json # TypeScript 配置
```
## 页面设计
### 1. 登录页面 (`loginPage`)
#### UI 组件安排
* 用户名输入框:使用 Element Plus 的 `el-input` 组件,用于输入用户名。
* 密码输入框:使用 Element Plus 的 `el-input` 组件,类型为 `password`,用于输入密码。
* 登录按钮:使用 Element Plus 的 `el-button` 组件,点击后触发登录逻辑。
#### 功能描述
* 用户输入用户名和密码后,点击登录按钮,前端通过 Axios 发送登录请求到后端 `/api/auth/login` 接口。
* 登录成功后,前端将获取到的 `access_token``refresh_token` 存储在 Pinia 状态管理中,并跳转到用户管理页面。
* 登录失败时,前端显示错误提示。
### 2. 用户管理页面 (`managePage`)
#### UI 组件安排
* 导航栏:使用 Element Plus 的 `el-menu` 组件,显示欢迎信息和当前用户信息
* 登出按钮:使用 Element Plus 的 `el-button` 组件,位于页面右上角,点击后触发登出逻辑。
* 用户列表:使用 Element Plus 的 `el-table` 组件,展示用户信息,包括 `ID``用户名``角色``描述` 等字段。
* 创建用户按钮:使用 Element Plus 的 `el-button` 组件,仅管理员可见,点击后弹出创建用户对话框。
* 修改按钮:使用 Element Plus 的 `el-button` 组件,位于每一行的操作列中,点击后弹出修改对话框。
* 删除按钮:使用 Element Plus 的 `el-button` 组件,位于每一行的操作列中,点击后触发删除逻辑。
* 修改对话框:使用 Element Plus 的 `el-dialog` 组件,用于修改用户信息。
#### 功能描述
* 用户列表展示:前端通过 Axios 发送请求到 `/api/users` 接口,获取用户列表数据,并在表格中展示。
* 创建用户:管理员点击创建用户按钮后,弹出创建用户对话框,填写信息后提交。
* 修改按钮的显示与隐藏:根据当前登录用户的角色,动态控制修改按钮的显示与隐藏。只有 `系统管理员``管理员` 可以看到并操作修改按钮。
* 删除按钮的显示与隐藏:根据当前登录用户的角色,动态控制删除按钮的显示与隐藏。只有 `系统管理员``管理员` 可以看到并操作删除按钮。
* 修改用户信息:点击修改按钮后,弹出修改对话框,用户可以在对话框中修改用户名、角色和描述信息。修改完成后,前端通过 Axios 发送请求到 `/api/users/{user_id}` 接口,更新用户信息。
* 删除用户:点击删除按钮后,前端通过 Axios 发送请求到 `/api/users/{user_id}` 接口,删除对应用户。
## 权限控制
### 1. 修改和删除按钮的权限控制
* 系统管理员、管理员:可以看到并操作所有用户的修改和删除按钮。
* 普通用户:无法看到修改和删除按钮,只能查看用户列表。
#### 实现逻辑
* 前端在用户登录成功后,从后端获取用户角色信息,并存储在 Pinia 状态管理中。
* 在用户管理页面中,前端根据当前用户的角色动态渲染表格中的操作列。如果当前用户是 `普通用户`,则不渲染修改和删除按钮;如果是 `系统管理员``管理员`,则渲染这些按钮。
### 2. 路由权限控制
* 登录页面:所有用户都可以访问。
* 用户管理页面:只有登录成功的用户才能访问。如果用户未登录,尝试访问用户管理页面时,前端应自动跳转到登录页面。
#### 实现逻辑
* 使用 Vue Router 的导航守卫(`beforeEach`)进行路由权限控制。在每次路由跳转前,检查 Pinia 中是否存储了有效的 `access_token`。如果没有,则跳转到登录页面。
## Token 管理
### 1. Access Token 和 Refresh Token 的存储
* 登录成功后,前端将 `access_token``refresh_token` 存储在 Pinia 状态管理中,并设置 `access_token` 的过期时间。
* 每次发送 API 请求时,前端从 Pinia 中获取 `access_token` 并添加到请求头中。
* 使用 pinia-plugin-persistedstate 插件将 token 存储在 localStorage 中,实现状态持久化
### 2. Token 自动刷新
* 前端在每次发送 API 请求前,检查 `access_token` 是否即将过期(例如,剩余有效期小于 5 分钟)。如果即将过期,前端使用 `refresh_token` 调用 `/api/auth/refresh` 接口,获取新的 `access_token``refresh_token`,并更新 Pinia 中的存储。
* 使用 Axios 的拦截器统一处理 token 刷新逻辑,避免在每个请求中重复编写代码。
## 状态管理
### 用户登录状态
* 使用 Pinia 存储用户登录状态,包括 `access_token``refresh_token`、用户角色、用户名、用户ID等信息。
* 在用户登出时,清除 Pinia 中的登录状态。
* 使用 pinia-plugin-persistedstate 插件进行状态持久化,在应用启动时自动从 localStorage 恢复状态
## 开发环境配置
1. 安装依赖:
```bash
npm install
```
2. 启动开发服务器:
```bash
npm run dev
```
3. 构建生产环境:
```bash
npm run build
```

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RBAC</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

603
frontend/openapi.json Normal file
View File

@ -0,0 +1,603 @@
{
"openapi": "3.0.2",
"info": {
"title": "User Management System",
"description": "API for managing users with role-based access control",
"version": "1.0.0"
},
"paths": {
"/api/auth/login": {
"post": {
"tags": [
"auth",
"auth"
],
"summary": "Login",
"operationId": "login_api_auth_login_post",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LoginRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TokenResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/auth/refresh": {
"post": {
"tags": [
"auth",
"auth"
],
"summary": "Refresh Token",
"operationId": "refresh_token_api_auth_refresh_post",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RefreshTokenRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TokenResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/users/": {
"get": {
"tags": [
"users",
"users"
],
"summary": "Get Users List",
"operationId": "get_users_list_api_users__get",
"parameters": [
{
"required": false,
"schema": {
"title": "Page",
"type": "integer",
"default": 1
},
"name": "page",
"in": "query"
},
{
"required": false,
"schema": {
"title": "Limit",
"type": "integer",
"default": 100
},
"name": "limit",
"in": "query"
},
{
"required": false,
"schema": {
"title": "Role",
"type": "string"
},
"name": "role",
"in": "query"
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"title": "Response Get Users List Api Users Get",
"type": "array",
"items": {
"$ref": "#/components/schemas/UserResponse"
}
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
},
"security": [
{
"OAuth2PasswordBearer": []
}
]
},
"post": {
"tags": [
"users",
"users"
],
"summary": "Create User",
"operationId": "create_user_api_users__post",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserCreate"
}
}
},
"required": true
},
"responses": {
"201": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
},
"security": [
{
"OAuth2PasswordBearer": []
}
]
}
},
"/api/users/{user_id}": {
"get": {
"tags": [
"users",
"users"
],
"summary": "Get User",
"operationId": "get_user_api_users__user_id__get",
"parameters": [
{
"required": true,
"schema": {
"title": "User Id",
"type": "integer"
},
"name": "user_id",
"in": "path"
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
},
"security": [
{
"OAuth2PasswordBearer": []
}
]
},
"put": {
"tags": [
"users",
"users"
],
"summary": "Update User",
"operationId": "update_user_api_users__user_id__put",
"parameters": [
{
"required": true,
"schema": {
"title": "User Id",
"type": "integer"
},
"name": "user_id",
"in": "path"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserUpdate"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
},
"security": [
{
"OAuth2PasswordBearer": []
}
]
},
"delete": {
"tags": [
"users",
"users"
],
"summary": "Delete User",
"operationId": "delete_user_api_users__user_id__delete",
"parameters": [
{
"required": true,
"schema": {
"title": "User Id",
"type": "integer"
},
"name": "user_id",
"in": "path"
}
],
"responses": {
"204": {
"description": "Successful Response"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
},
"security": [
{
"OAuth2PasswordBearer": []
}
]
}
}
},
"components": {
"schemas": {
"HTTPValidationError": {
"title": "HTTPValidationError",
"type": "object",
"properties": {
"detail": {
"title": "Detail",
"type": "array",
"items": {
"$ref": "#/components/schemas/ValidationError"
}
}
}
},
"LoginRequest": {
"title": "LoginRequest",
"required": [
"username",
"password"
],
"type": "object",
"properties": {
"username": {
"title": "Username",
"type": "string"
},
"password": {
"title": "Password",
"type": "string"
}
}
},
"RefreshTokenRequest": {
"title": "RefreshTokenRequest",
"required": [
"refresh_token"
],
"type": "object",
"properties": {
"refresh_token": {
"title": "Refresh Token",
"type": "string"
}
}
},
"TokenResponse": {
"title": "TokenResponse",
"required": [
"access_token",
"refresh_token",
"token_type",
"access_token_exp",
"refresh_token_exp"
],
"type": "object",
"properties": {
"access_token": {
"title": "Access Token",
"type": "string"
},
"refresh_token": {
"title": "Refresh Token",
"type": "string"
},
"token_type": {
"title": "Token Type",
"type": "string"
},
"access_token_exp": {
"title": "Access Token Exp",
"type": "integer"
},
"refresh_token_exp": {
"title": "Refresh Token Exp",
"type": "integer"
}
}
},
"UserCreate": {
"title": "UserCreate",
"required": [
"username",
"password"
],
"type": "object",
"properties": {
"username": {
"title": "Username",
"maxLength": 50,
"type": "string",
"description": "用户名"
},
"role": {
"allOf": [
{
"$ref": "#/components/schemas/UserRole"
}
],
"description": "用户角色",
"default": "user"
},
"description": {
"title": "Description",
"maxLength": 255,
"type": "string",
"description": "用户描述"
},
"password": {
"title": "Password",
"maxLength": 255,
"minLength": 6,
"type": "string",
"description": "用户密码"
}
}
},
"UserResponse": {
"title": "UserResponse",
"required": [
"username",
"id",
"created_at",
"updated_at"
],
"type": "object",
"properties": {
"username": {
"title": "Username",
"maxLength": 50,
"type": "string",
"description": "用户名"
},
"role": {
"allOf": [
{
"$ref": "#/components/schemas/UserRole"
}
],
"description": "用户角色",
"default": "user"
},
"description": {
"title": "Description",
"maxLength": 255,
"type": "string",
"description": "用户描述"
},
"id": {
"title": "Id",
"type": "integer",
"description": "用户ID"
},
"created_at": {
"title": "Created At",
"type": "string",
"description": "创建时间",
"format": "date-time"
},
"updated_at": {
"title": "Updated At",
"type": "string",
"description": "更新时间",
"format": "date-time"
}
}
},
"UserRole": {
"title": "UserRole",
"enum": [
"system_admin",
"admin",
"user"
],
"type": "string",
"description": "An enumeration."
},
"UserUpdate": {
"title": "UserUpdate",
"type": "object",
"properties": {
"username": {
"title": "Username",
"maxLength": 50,
"type": "string",
"description": "用户名"
},
"role": {
"allOf": [
{
"$ref": "#/components/schemas/UserRole"
}
],
"description": "用户角色"
},
"description": {
"title": "Description",
"maxLength": 255,
"type": "string",
"description": "用户描述"
}
}
},
"ValidationError": {
"title": "ValidationError",
"required": [
"loc",
"msg",
"type"
],
"type": "object",
"properties": {
"loc": {
"title": "Location",
"type": "array",
"items": {
"anyOf": [
{
"type": "string"
},
{
"type": "integer"
}
]
}
},
"msg": {
"title": "Message",
"type": "string"
},
"type": {
"title": "Error Type",
"type": "string"
}
}
}
},
"securitySchemes": {
"OAuth2PasswordBearer": {
"type": "oauth2",
"flows": {
"password": {
"scopes": {},
"tokenUrl": "auth/login"
}
}
}
}
}
}

4049
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
frontend/package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.9",
"element-plus": "^2.9.3",
"jwt-decode": "^4.0.0",
"pinia": "^2.3.1",
"pinia-plugin-persistedstate": "^4.2.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@types/node": "^22.13.4",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/tsconfig": "^0.7.0",
"typescript": "~5.6.2",
"vite": "^6.0.5",
"vite-plugin-vue-devtools": "^7.7.2",
"vue-tsc": "^2.2.0"
}
}

12
frontend/src/App.vue Normal file
View File

@ -0,0 +1,12 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<div id="app">
<RouterView />
</div>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,30 @@
import apiClient from './axiosInstance';
import type { TokenResponse } from './types';
const API_BASE_URL = '/auth';
export const authService = {
async login(username: string, password: string): Promise<TokenResponse> {
try {
const response = await apiClient.post(`${API_BASE_URL}/login`, { username, password });
return response.data;
} catch (error: any) {
if (error.response?.data?.detail?.[0]?.msg) {
throw new Error(error.response.data.detail[0].msg);
}
throw new Error('Login failed');
}
},
async refreshToken(refreshToken: string): Promise<TokenResponse> {
try {
const response = await apiClient.post(`${API_BASE_URL}/refresh`, { refresh_token: refreshToken });
return response.data;
} catch (error: any) {
if (error.response?.data?.detail?.[0]?.msg) {
throw new Error(error.response.data.detail[0].msg);
}
throw new Error('Token refresh failed');
}
}
};

View File

@ -0,0 +1,70 @@
import axios from 'axios';
import { userStore } from '../store/userStore';
import router from '../router';
// 创建axios实例
const apiClient = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
});
// 请求拦截器
apiClient.interceptors.request.use(
async (config) => {
const store = userStore();
// 只有已登录状态才添加Authorization头
if (store.isLoggedIn) {
const accessToken = store.accessToken;
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 响应拦截器
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const store = userStore(); // 获取用户状态
// 如果未登录,直接返回错误
if (!store.isLoggedIn) {
return Promise.reject(error);
}
const originalRequest = error.config;
// 如果401错误且不是刷新token的请求
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// 尝试刷新 token
await store.refreshTokenMethod();
// 更新请求头并重试原始请求
originalRequest.headers.Authorization = `Bearer ${store.accessToken}`;
return apiClient(originalRequest);
} catch (refreshError) {
// 刷新 token 失败,清除登录状态并跳转登录页
store.logout();
router.push({ name: 'login' });
return Promise.reject(refreshError);
}
}
// 非 401 错误或已重试过,直接返回错误
return Promise.reject(error);
}
);
export default apiClient;

41
frontend/src/api/types.ts Normal file
View File

@ -0,0 +1,41 @@
export interface UserResponse {
id: number;
username: string;
role: UserRole;
description?: string;
created_at: string;
updated_at: string;
}
export interface UserCreate {
username: string;
password: string;
role?: UserRole;
description?: string;
}
export interface UserUpdate {
username?: string;
role?: UserRole;
description?: string;
}
export interface TokenResponse {
access_token: string;
refresh_token: string;
token_type: string;
access_token_exp: number;
refresh_token_exp: number;
}
export interface HTTPValidationError {
detail: ValidationError[];
}
export interface ValidationError {
loc: (string | number)[];
msg: string;
type: string;
}
export type UserRole = 'system_admin' | 'admin' | 'user';

View File

@ -0,0 +1,32 @@
import apiClient from './axiosInstance';
import type { UserResponse, UserCreate, UserUpdate } from './types';
const API_BASE_URL = '/users';
export const userService = {
async getUsers(page = 1, limit = 100, role?: string): Promise<UserResponse[]> {
const response = await apiClient.get(API_BASE_URL, {
params: { page, limit, role }
});
return response.data;
},
async createUser(userData: UserCreate): Promise<UserResponse> {
const response = await apiClient.post(API_BASE_URL, userData);
return response.data;
},
async updateUser(userId: number, userData: UserUpdate): Promise<UserResponse> {
const response = await apiClient.put(`${API_BASE_URL}/${userId}`, userData);
return response.data;
},
async deleteUser(userId: number): Promise<void> {
await apiClient.delete(`${API_BASE_URL}/${userId}`);
},
async getUser(userId: number): Promise<UserResponse> {
const response = await apiClient.get(`${API_BASE_URL}/${userId}`);
return response.data;
}
};

View File

@ -0,0 +1,118 @@
<template>
<el-dialog v-model="dialogVisible" :title="props.mode === 'create' ? '创建用户' : '修改用户信息'">
<el-form :model="editForm">
<el-form-item v-if="props.mode === 'edit'" label="用户ID">
<el-input v-model="editForm.id" disabled />
</el-form-item>
<el-form-item label="用户名">
<el-input v-model="editForm.username" />
</el-form-item>
<el-form-item label="角色">
<el-select v-model="editForm.role">
<el-option label="系统管理员" value="system_admin" />
<el-option label="管理员" value="admin" />
<el-option label="普通用户" value="user" />
</el-select>
</el-form-item>
<el-form-item v-if="props.mode === 'create'" label="密码">
<el-input v-model="editForm.password" type="password" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="editForm.description" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleConfirm">确认</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { UserResponse, UserCreate } from '@/api/types'
import { userService } from '@/api/userService'
const props = defineProps<{
mode: 'create' | 'edit',
userId?: number
}>()
const dialogVisible = ref(false)
const options = ref<{ userId?: number, mode?: 'create' | 'edit', onConfirm?: () => void }>({})
const editForm = ref<UserResponse & { password?: string }>({
id: 0,
username: '',
role: 'user',
description: '',
created_at: '',
updated_at: '',
password: ''
})
const emit = defineEmits(['confirm'])
const open = async (opts: { userId?: number, mode?: 'create' | 'edit', onConfirm?: () => void }) => {
options.value = opts
if (options.value.mode === 'edit' && options.value.userId) {
const user = await userService.getUser(options.value.userId)
editForm.value = { ...user }
} else {
editForm.value = {
id: 0,
username: '',
role: 'user',
description: '',
created_at: '',
updated_at: ''
}
}
if (options.value.onConfirm) {
emit('confirm', options.value.onConfirm)
}
dialogVisible.value = true
}
const handleConfirm = async () => {
try {
if (props.mode === 'create') {
// API
const { id, created_at, updated_at, ...createData } = editForm.value
if (!createData.password) {
throw new Error('密码不能为空')
}
await userService.createUser({
username: createData.username,
password: createData.password,
role: createData.role,
description: createData.description
} as UserCreate)
} else {
// API
const { id, created_at, updated_at, ...updateData } = editForm.value
await userService.updateUser(id, updateData)
}
emit('confirm', editForm.value)
if (options.value.onConfirm) {
options.value.onConfirm()
}
ElMessage({
type: 'success',
message: props.mode === 'create' ? '用户创建成功' : '用户信息更新成功'
})
dialogVisible.value = false
} catch (error) {
console.error('操作失败:', error)
ElMessage({
type: 'error',
message: props.mode === 'create' ? '用户创建失败' : '用户信息更新失败'
})
}
}
defineExpose({
open,
handleConfirm
})
</script>

View File

@ -0,0 +1,74 @@
<template>
<el-table :data="users" style="width: 100%">
<el-table-column prop="id" label="ID" width="100" />
<el-table-column prop="username" label="用户名" />
<el-table-column prop="role" label="角色" />
<el-table-column prop="description" label="描述" />
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="scope">
{{ new Date(scope.row.created_at).toLocaleString() }}
</template>
</el-table-column>
<el-table-column prop="updated_at" label="更新时间" width="180">
<template #default="scope">
{{ new Date(scope.row.updated_at).toLocaleString() }}
</template>
</el-table-column>
<el-table-column label="操作" width="150" v-if="isAdmin">
<template #default="scope">
<el-button type="primary" size="small" @click="handleEdit(scope.row)">修改</el-button>
<el-button type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<EditUserDialog ref="editDialogRef" mode="edit" />
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
import { userService } from '../api/userService';
import { userStore } from '../store/userStore';
import type { UserResponse } from '../api/types';
import EditUserDialog from './EditUserDialog.vue';
const store = userStore();
const users = ref<UserResponse[]>([]);
const isAdmin = store.isAdmin;
const fetchUsers = async () => {
try {
const data = await userService.getUsers();
console.log('获取用户列表成功:', data);
users.value = data;
} catch (error) {
console.error('获取用户列表失败:', error);
}
};
const editDialogRef = ref<InstanceType<typeof EditUserDialog>>();
const handleEdit = (user: UserResponse) => {
editDialogRef.value?.open({
userId: user.id,
mode: 'edit',
onConfirm: () => fetchUsers()
});
};
const handleDelete = async (user: UserResponse) => {
try {
await userService.deleteUser(user.id);
await fetchUsers();
} catch (error) {
console.error('删除用户失败:', error);
}
};
onMounted(() => {
fetchUsers();
});
defineExpose({
fetchUsers
});
</script>

46
frontend/src/main.ts Normal file
View File

@ -0,0 +1,46 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import './style.css'
import App from './App.vue'
import router from './router'
import apiClient from './api/axiosInstance'
import { userStore } from './store/userStore'
const pinia = createPinia()
const app = createApp(App)
// 配置路由和状态管理
app.use(router)
app.use(pinia)
app.use(ElementPlus)
// 初始化userStore
const store = userStore()
// 使用插件进行持久化,监听 store 变化并保存到本地存储
store.$subscribe((_, state) => {
localStorage.setItem('authStore', JSON.stringify(state));
});
// 监听 action 执行,登录和刷新令牌后保存状态到本地存储
store.$onAction(({ name, store, after }) => {
if (name === 'login' || name === 'refreshTokenMethod') {
after(() => {
localStorage.setItem('authStore', JSON.stringify(store.$state));
});
}
});
// 初始化时从本地存储恢复状态
const persistedState = JSON.parse(localStorage.getItem('authStore') || '{}');
if (persistedState.accessToken && persistedState.refreshTokenToken && persistedState.role && persistedState.username && persistedState.userId !== null) {
store.$patch(persistedState);
}
// 设置axios拦截器
apiClient
app.mount('#app')

View File

@ -0,0 +1,44 @@
import { createRouter, createWebHistory } from 'vue-router'
import { userStore } from '@/store/userStore'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginPage.vue')
},
{
path: '/manage',
name: 'manage',
component: () => import('@/views/ManagePage.vue'),
meta: { requiresAuth: true }
},
{
path: '/:pathMatch(.*)*',
redirect: '/manage'
}
]
})
router.beforeEach((to, _, next) => {
//const userStore = useUserStore()
// 如果路由需要认证但用户未登录
if (to.meta.requiresAuth && !userStore().isLoggedIn) {
next({ name: 'login' })
}
// 如果用户已登录但访问登录页
else if (to.name === 'login' && userStore().isLoggedIn) {
next({ name: 'manage' })
}
// 其他情况正常导航
else {
next()
}
})
export default router

View File

@ -0,0 +1,67 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { authService } from '../api/authService';
import { jwtDecode } from 'jwt-decode';
// 定义 store包含用户认证相关状态和方法
export const userStore = defineStore('user', () => {
const accessToken = ref('');
const refreshToken = ref('');
const role = ref('');
const username = ref('');
const id = ref<number | null>(null);
// 计算属性,用于快速判断用户是否登录
const isLoggedIn = computed(() => !!accessToken.value);
const isAdmin = computed(() => ['system_admin', 'admin'].includes(role.value));
// 设置访问和刷新令牌的方法
function setTokens(tokens: { access_token: string; refresh_token: string }) {
accessToken.value = tokens.access_token;
refreshToken.value = tokens.refresh_token;
}
// 设置用户角色的方法
function setRole(userRole: string) {
role.value = userRole;
}
// 用户登录方法,调用 authService.login 获取令牌并设置状态
async function login(usernameParam: string, password: string) {
try {
const { access_token, refresh_token } = await authService.login(usernameParam, password);
const decoded = jwtDecode<{ sub: string; role: string; username: string; id: string }>(access_token);
setTokens({ access_token, refresh_token });
setRole(decoded.role);
username.value = decoded.username;
id.value = parseInt(decoded.id);
} catch (error) {
console.error('Login failed:', error);
const err = new Error('登录失败');
err.name = 'LoginError';
err.cause = error;
throw err;
}
}
// 用户登出方法,清空所有状态
async function logout() {
accessToken.value = '';
refreshToken.value = '';
role.value = '';
username.value = '';
id.value = null;
}
// 刷新访问令牌的方法,调用 authService.refreshToken 获取新令牌并设置状态
async function refreshTokenMethod() {
try {
const { access_token, refresh_token } = await authService.refreshToken(refreshToken.value);
setTokens({ access_token, refresh_token });
} catch (error) {
console.error('Token refresh failed:', error);
}
}
return { accessToken, refreshTokenToken: refreshToken, role, username, userId: id, isLoggedIn, isAdmin, login, logout, refreshTokenMethod };
});

79
frontend/src/style.css Normal file
View File

@ -0,0 +1,79 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@ -0,0 +1,87 @@
<template>
<div class="login-container">
<h2 class="login-title">用户登录</h2>
<el-form :model="form" label-width="80px">
<el-form-item label="用户名">
<el-input v-model="form.username" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password" type="password" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleLogin">登录</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { userStore } from '@/store/userStore'
import { ElMessage } from 'element-plus'
interface LoginForm {
username: string
password: string
}
const router = useRouter()
const store = userStore()
const form = ref<LoginForm>({
username: '',
password: ''
})
const handleLogin = async () => {
try {
const { username, password } = form.value
if (!username || !password) {
throw new Error('用户名和密码不能为空')
}
await store.login(username, password)
router.push({ name: 'manage' })
} catch (error: any) {
ElMessage.error(error.message || '登录失败')
}
}
</script>
<style scoped>
.login-container {
max-width: 400px;
margin: 100px auto;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
padding: 40px;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.login-title {
text-align: center;
margin-bottom: 30px;
color: #303133;
font-size: 24px;
}
.el-form-item {
margin-bottom: 24px;
}
.el-input {
width: 100%;
}
.el-button {
width: 100%;
margin-top: 16px;
}
@media (max-width: 480px) {
.login-container {
margin: 50px 20px;
padding: 30px;
}
}
</style>

View File

@ -0,0 +1,122 @@
<template>
<div class="manage-page">
<el-menu
mode="horizontal"
class="nav-bar"
>
<el-menu-item index="1" class="nav-title">欢迎</el-menu-item>
<el-menu-item index="2" class="user-info">
<div class="user-detail">
<span class="username">{{ userStore.username }}</span>
<span class="role">({{ userStore.role }})</span>
</div>
</el-menu-item>
<el-menu-item index="3">
<el-button
type="danger"
class="logout-btn"
@click="handleLogout"
>
登出
</el-button>
</el-menu-item>
</el-menu>
<el-card class="page-container">
<el-text class="system-title" type="primary" size="large" tag="h1">
用户管理系统
</el-text>
<el-button
type="primary"
class="create-btn"
@click="handleCreateUser"
v-if="userStore.isAdmin"
>
创建用户
</el-button>
<UserTable/>
<EditUserDialog ref="editUserDialog" mode="create" />
</el-card>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { userStore as store } from '@/store/userStore'
import UserTable from '@/components/UserTable.vue'
import EditUserDialog from '@/components/EditUserDialog.vue'
const router = useRouter()
const userStore = store()
const editUserDialog = ref<InstanceType<typeof EditUserDialog>>()
const userTableRef = ref<InstanceType<typeof UserTable>>()
const handleLogout = () => {
userStore.logout()
router.push({ name: 'login' })
}
const handleCreateUser = () => {
editUserDialog.value?.open({
mode: 'create',
onConfirm: () => {
userTableRef.value?.fetchUsers()
console.log('用户创建成功,刷新用户列表')
}
})
}
onMounted(() => {
userTableRef.value?.fetchUsers()
})
</script>
<style scoped>
.manage-page {
padding: 20px;
}
.nav-bar {
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-title {
font-size: 18px;
font-weight: bold;
}
.user-info {
display: flex;
flex-direction: column;
}
.user-detail {
margin-top: 4px;
color: #606266;
font-size: 14px;
}
.username {
font-weight: 500;
}
.role {
margin-left: 8px;
color: #909399;
}
.logout-btn {
margin: 0;
}
.system-title {
margin-bottom: 20px;
font-size: 24px;
font-weight: 500;
letter-spacing: 1px;
}
</style>

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,18 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

7
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

32
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,32 @@
import { fileURLToPath, URL } from 'node:url';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [
vue()
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
host: '0.0.0.0',
strictPort: true,
open: true,
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
secure: false,
rewrite: (path) => path,
followRedirects: true, // Ensure 307 redirects are handled correctly
}
}
},
preview: {
host: '0.0.0.0',
strictPort: true
}
});