Add 'frontend/' from commit '94f710850fa74ad3aaca5b65318f2fec9e1a2cdf'

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

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" />