style:新增登录界面 完善api请求逻辑
This commit is contained in:
2
components.d.ts
vendored
2
components.d.ts
vendored
@@ -14,6 +14,7 @@ declare module 'vue' {
|
||||
CardItem: typeof import('./src/components/cardItem/index.vue')['default']
|
||||
ElAside: typeof import('element-plus/es')['ElAside']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
ElDatePick: typeof import('element-plus/es')['ElDatePick']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
@@ -27,6 +28,7 @@ declare module 'vue' {
|
||||
ElHeader: typeof import('element-plus/es')['ElHeader']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElLink: typeof import('element-plus/es')['ElLink']
|
||||
ElMain: typeof import('element-plus/es')['ElMain']
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@micro-zoe/micro-app": "^1.0.0-rc.28",
|
||||
"element-plus": "^2.13.0",
|
||||
"pinia": "^3.0.4",
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@element-plus/icons-vue':
|
||||
specifier: ^2.3.2
|
||||
version: 2.3.2(vue@3.5.26(typescript@5.9.3))
|
||||
'@micro-zoe/micro-app':
|
||||
specifier: ^1.0.0-rc.28
|
||||
version: 1.0.0-rc.28
|
||||
|
||||
@@ -9,25 +9,19 @@
|
||||
@select="handleMenuSelect"
|
||||
router
|
||||
>
|
||||
<template v-for="item in menuList" :key="item.path">
|
||||
<template v-for="item in menuList" :key="`${item.path}-${item.meta?.title}`">
|
||||
<el-sub-menu v-if="item.children && item.children.length > 0">
|
||||
<template #title>
|
||||
<el-icon>
|
||||
<location />
|
||||
</el-icon>
|
||||
<el-icon v-if="item.meta?.icon"><component :is="item.meta.icon" /></el-icon>
|
||||
<span>{{ item.meta.title }}</span>
|
||||
</template>
|
||||
<el-menu-item :index="`${item.path}/${row.path}`" v-for="(row,key) in item.children" :key="key">
|
||||
<el-icon>
|
||||
<location />
|
||||
</el-icon>
|
||||
<el-menu-item :index="resolvePath(item.path, row.path)" v-for="(row,key) in item.children" :key="resolvePath(item.path, row.path)">
|
||||
<el-icon v-if="row.meta?.icon"><component :is="row.meta.icon" /></el-icon>
|
||||
<template #title>{{ row.meta.title }}</template>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-menu-item v-else :index="item.path">
|
||||
<el-icon>
|
||||
<location />
|
||||
</el-icon>
|
||||
<el-icon v-if="item.meta?.icon"><component :is="item.meta.icon" /></el-icon>
|
||||
<template #title>{{ item.meta.title }}</template>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
@@ -37,6 +31,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Location } from '@element-plus/icons-vue'
|
||||
defineOptions({ name: "standardMenu" })
|
||||
const route = useRoute();
|
||||
|
||||
const {mode="vertical",menuList,isCollapse,activeMenu} = defineProps<{
|
||||
mode?: 'vertical' | 'horizontal'
|
||||
@@ -51,9 +46,30 @@ const emit = defineEmits<{
|
||||
|
||||
const activeIndex = computed(() => {
|
||||
// 如果传入了 activeMenu,使用它;否则使用默认值
|
||||
return activeMenu || '1-1'
|
||||
return activeMenu || route.path
|
||||
})
|
||||
|
||||
/**
|
||||
* 智能拼接路径
|
||||
* @param parentPath 父级路径
|
||||
* @param childPath 子级路径
|
||||
*/
|
||||
const resolvePath = (parentPath: string, childPath: string) => {
|
||||
// 1. 如果子路径是绝对路径(以 / 开头),直接返回子路径
|
||||
if (childPath.startsWith('/')) {
|
||||
return childPath;
|
||||
}
|
||||
|
||||
// 2. 移除父路径末尾的斜杠
|
||||
const parent = parentPath.endsWith('/') ? parentPath.slice(0, -1) : parentPath;
|
||||
|
||||
// 3. 移除子路径开头的斜杠(虽然第一步已经过滤了,但为了健壮性保留)
|
||||
const child = childPath.startsWith('/') ? childPath.slice(1) : childPath;
|
||||
|
||||
// 4. 返回拼接后的路径
|
||||
return `${parent}/${child}`;
|
||||
};
|
||||
|
||||
// 处理菜单选中事件
|
||||
const handleMenuSelect = (index: string) => {
|
||||
// 如果是水平模式(顶部菜单),触发选中事件
|
||||
|
||||
@@ -9,6 +9,12 @@ import en from "element-plus/es/locale/lang/en";
|
||||
import Directives from '@/utils/directives';
|
||||
import '@/styles/common.scss';
|
||||
|
||||
// 全局导入element ui样式类
|
||||
import 'element-plus/es/components/message/style/css'
|
||||
import 'element-plus/es/components/notification/style/css'
|
||||
import 'element-plus/es/components/message-box/style/css'
|
||||
import 'element-plus/es/components/loading/style/css'
|
||||
|
||||
const pinia = createPinia();
|
||||
const app = createApp(App);
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ defineOptions({
|
||||
});
|
||||
|
||||
const userStore = useUserStore();
|
||||
const router = useRouter();
|
||||
|
||||
// 响应式断点(小屏阈值,小于此值视为小屏)
|
||||
const BREAKPOINT = 1024;
|
||||
@@ -158,7 +159,16 @@ const handleSideMenuSelect = (menuPath: string) => {
|
||||
|
||||
// 初始化:默认选中第一个菜单
|
||||
onMounted(() => {
|
||||
if (topLevelMenuList.value.length > 0) {
|
||||
|
||||
const currentRoutePath = router.currentRoute.value.path;
|
||||
const matchedTopMenu = topLevelMenuList.value.find(menu =>
|
||||
currentRoutePath.startsWith(`${menu.path}/`) || currentRoutePath === menu.path
|
||||
);
|
||||
|
||||
if (matchedTopMenu && matchedTopMenu.path) {
|
||||
selectedTopMenu.value = matchedTopMenu.path;
|
||||
} else if (topLevelMenuList.value.length > 0) {
|
||||
// 否则默认选中第一个菜单
|
||||
const firstMenu = topLevelMenuList.value[0];
|
||||
if (firstMenu && firstMenu.path) {
|
||||
selectedTopMenu.value = firstMenu.path;
|
||||
|
||||
@@ -1,48 +1,89 @@
|
||||
<template>
|
||||
<div class="mj-login">
|
||||
<div class="login-container">
|
||||
<div class="login-container">
|
||||
<div class="left-section">
|
||||
<div class="logo-wrapper">
|
||||
<div class="logo-icon">M</div>
|
||||
<span class="logo-text">M Version <span>SaaS</span></span>
|
||||
</div>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<h1 class="main-title">专注业务增长</h1>
|
||||
<h1 class="sub-title">智能高效管理</h1>
|
||||
<p class="description">
|
||||
为现代企业打造的一站式后台管理方案。集成商机、合同、项目及招聘管理,全方位提升团队效能。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="footer-users">
|
||||
<div class="avatar-group">
|
||||
<img
|
||||
v-for="i in 4"
|
||||
:key="i"
|
||||
:src="`https://i.pravatar.cc/100?img=${i + 10}`"
|
||||
alt="user"
|
||||
/>
|
||||
</div>
|
||||
<span class="user-count">超过 5,000+ 行业领先企业正在使用</span>
|
||||
</div>
|
||||
|
||||
<div class="bg-blur-circle"></div>
|
||||
</div>
|
||||
|
||||
<div class="right-section">
|
||||
<div class="login-box">
|
||||
<h2 class="login-title">系统登录</h2>
|
||||
<h2 class="welcome-title">欢迎回来</h2>
|
||||
<p class="welcome-sub">请登录您的账号以继续</p>
|
||||
|
||||
<el-form
|
||||
ref="loginFormRef"
|
||||
:model="loginForm"
|
||||
:rules="loginRules"
|
||||
label-width="0"
|
||||
label-position="top"
|
||||
class="login-form"
|
||||
ref="loginFormRef"
|
||||
>
|
||||
<el-form-item prop="username">
|
||||
<el-form-item label="账号/邮箱" class="account-item">
|
||||
<el-input
|
||||
v-model="loginForm.username"
|
||||
placeholder="请输入用户名"
|
||||
size="large"
|
||||
prefix-icon="User"
|
||||
clearable
|
||||
placeholder="admin@figmamake.com"
|
||||
:prefix-icon="Message"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
|
||||
<el-form-item class="password-item">
|
||||
<template #label>
|
||||
<div class="password-label">
|
||||
<span>密码</span>
|
||||
<el-link type="primary" underline="never" class="forgot-pass"
|
||||
>忘记密码?</el-link
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<el-input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
prefix-icon="Lock"
|
||||
show-password
|
||||
clearable
|
||||
@keyup.enter="handleLogin"
|
||||
placeholder="••••••••"
|
||||
:prefix-icon="Lock"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
class="login-button"
|
||||
@click="handleLogin"
|
||||
>
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<div class="form-options">
|
||||
<el-checkbox v-model="loginForm.remember">
|
||||
<span class="checkbox-text">记住此设备</span>
|
||||
</el-checkbox>
|
||||
</div>
|
||||
|
||||
<el-button type="primary" class="login-btn" @click="handleLogin">
|
||||
登录系统 <el-icon class="el-icon--right"><Right /></el-icon>
|
||||
</el-button>
|
||||
</el-form>
|
||||
|
||||
<div class="form-footer">
|
||||
<span>© 2025 智视界保留所有权利</span>
|
||||
<div class="links">
|
||||
<el-link underline="never">隐私政策</el-link>
|
||||
<el-link underline="never">服务条款</el-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,6 +93,7 @@
|
||||
import { reactive, ref } from "vue";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
|
||||
import { Message, Lock, Right } from "@element-plus/icons-vue";
|
||||
import { login } from "@/api";
|
||||
import { useUserStore } from "@/store";
|
||||
import useTokenRefresh from "@/hooks/useTokenRefresh";
|
||||
@@ -74,7 +116,12 @@ const loginForm = reactive({
|
||||
const loginRules: FormRules = {
|
||||
username: [
|
||||
{ required: true, message: "请输入用户名", trigger: "blur" },
|
||||
{ min: 3, max: 20, message: "用户名长度在 3 到 20 个字符", trigger: "blur" },
|
||||
{
|
||||
min: 3,
|
||||
max: 20,
|
||||
message: "用户名长度在 3 到 20 个字符",
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: "请输入密码", trigger: "blur" },
|
||||
@@ -96,7 +143,8 @@ const handleLogin = async () => {
|
||||
});
|
||||
|
||||
if (response.code === 0 && response.data) {
|
||||
const { accessToken, refreshToken, expiresIn, userInfo } = response.data;
|
||||
const { accessToken, refreshToken, expiresIn, userInfo } =
|
||||
response.data;
|
||||
|
||||
// 保存 token
|
||||
setTokens({
|
||||
@@ -128,39 +176,190 @@ const handleLogin = async () => {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mj-login {
|
||||
width: 100%;
|
||||
// 变量定义
|
||||
$primary-color: #2b5cff;
|
||||
$text-main: #1a1a1a;
|
||||
$text-secondary: #999;
|
||||
$bg-light: #f8faff;
|
||||
|
||||
.login-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// --- 左侧展示区 ---
|
||||
.left-section {
|
||||
flex: 1.2;
|
||||
background-color: $bg-light;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 80px;
|
||||
|
||||
.logo-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
z-index: 2;
|
||||
.logo-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: $primary-color;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.logo-text {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
span {
|
||||
color: $primary-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
z-index: 2;
|
||||
.main-title {
|
||||
font-size: 48px;
|
||||
color: $text-main;
|
||||
margin: 0;
|
||||
}
|
||||
.sub-title {
|
||||
font-size: 48px;
|
||||
color: $primary-color;
|
||||
margin: 10px 0 0 0;
|
||||
}
|
||||
.description {
|
||||
color: #666;
|
||||
max-width: 460px;
|
||||
line-height: 1.8;
|
||||
margin-top: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-users {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 2;
|
||||
.avatar-group img {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid white;
|
||||
margin-right: -10px;
|
||||
}
|
||||
.user-count {
|
||||
color: $text-secondary;
|
||||
font-size: 14px;
|
||||
margin-left: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.bg-blur-circle {
|
||||
position: absolute;
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(92, 103, 255, 0.12) 0%,
|
||||
rgba(255, 122, 250, 0.08) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
filter: blur(80px);
|
||||
top: 15%;
|
||||
left: 15%;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 右侧登录区 ---
|
||||
.right-section {
|
||||
flex: 1;
|
||||
background: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
|
||||
.login-container {
|
||||
.login-box {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 20px;
|
||||
max-width: 440px;
|
||||
padding: 0 40px;
|
||||
|
||||
.login-box {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
||||
.welcome-title {
|
||||
font-size: 32px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.welcome-sub {
|
||||
color: $text-secondary;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
.login-form {
|
||||
// 深度作用选择器统一处理 Element Plus 内部样式
|
||||
:deep(.el-form-item__label) {
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
.login-button {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
:deep(.el-input__wrapper) {
|
||||
height: 54px;
|
||||
border-radius: 12px;
|
||||
box-shadow: none;
|
||||
transition: none;
|
||||
background-color: #f5f7fa;
|
||||
border: 1px solid transparent;
|
||||
|
||||
&.is-focus {
|
||||
box-shadow: none;
|
||||
background-color: #fff;
|
||||
border-color: #dce4ff;
|
||||
}
|
||||
}
|
||||
|
||||
.password-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
.el-link {
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
height: 54px;
|
||||
border-radius: 12px;
|
||||
background-color: $primary-color;
|
||||
margin-top: 25px;
|
||||
font-size: 16px;
|
||||
border: none;
|
||||
box-shadow: 0 8px 16px rgba(43, 92, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
margin-top: 80px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
color: $text-secondary;
|
||||
.links {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,12 @@
|
||||
<div class="mj-organization">
|
||||
<!-- 顶部的tabs菜单 -->
|
||||
<div class="organization-tabs">
|
||||
<stageBreadcrumbs title="组织管理">
|
||||
<template #content>
|
||||
内容占位
|
||||
</template>
|
||||
<template #action>
|
||||
<el-button type="primary" :icon="Plus" plain>新增集团</el-button>
|
||||
</template>
|
||||
</stageBreadcrumbs>
|
||||
<stageBreadcrumbs title="组织管理">
|
||||
<template #content> 内容占位 </template>
|
||||
<template #action>
|
||||
<el-button type="primary" :icon="Plus" plain>新增集团</el-button>
|
||||
</template>
|
||||
</stageBreadcrumbs>
|
||||
</div>
|
||||
<!-- 底部内容(组织架构+信息展示) -->
|
||||
<div class="organization-content">
|
||||
@@ -53,7 +51,7 @@
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Plus, Search } from "@element-plus/icons-vue";
|
||||
import stageBreadcrumbs from '@/components/stageBreadcrumbs/index.vue';
|
||||
import stageBreadcrumbs from "@/components/stageBreadcrumbs/index.vue";
|
||||
defineOptions({ name: "Organization" });
|
||||
|
||||
const addValue = ref("");
|
||||
@@ -61,7 +59,6 @@ const search = ref("");
|
||||
|
||||
const activeName = ref("first");
|
||||
|
||||
|
||||
interface Tree {
|
||||
label: string;
|
||||
children?: Tree[];
|
||||
@@ -135,6 +132,7 @@ const defaultProps = {
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@use "sass:math";
|
||||
.mj-organization {
|
||||
.mj-organization-card {
|
||||
border-radius: 16px;
|
||||
@@ -144,8 +142,7 @@ const defaultProps = {
|
||||
box-shadow: 0 0 6px #e9e8e8;
|
||||
}
|
||||
|
||||
.organization-tabs{
|
||||
|
||||
.organization-tabs {
|
||||
}
|
||||
|
||||
.organization-content {
|
||||
@@ -168,7 +165,7 @@ const defaultProps = {
|
||||
.org-tree-list {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: $mj-padding-standard/2 $mj-padding-standard;
|
||||
padding: math.div($mj-padding-standard, 2) $mj-padding-standard;
|
||||
}
|
||||
.org-bottom-add {
|
||||
border-top: 1px solid #f1f5f9;
|
||||
|
||||
@@ -1,199 +1,127 @@
|
||||
import axios from "axios";
|
||||
import type {AxiosInstance, AxiosRequestConfig, AxiosResponse} from 'axios';
|
||||
import useTokenRefresh from "@/hooks/useTokenRefresh";
|
||||
import 'element-plus/es/components/notification/style/css'
|
||||
import { ElNotification } from 'element-plus'
|
||||
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||
import { ElNotification } from 'element-plus';
|
||||
import { getMockData, shouldUseMock } from '@/mock' //mock数据信息
|
||||
|
||||
|
||||
const baseUrl = import.meta.env.VITE_APP_BASE_API;
|
||||
|
||||
// 定义响应数据类型
|
||||
interface ApiResponse<T = any> {
|
||||
code: number;
|
||||
data: T;
|
||||
msg: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
// 1. 锁和队列定义在类外部,确保全局唯一
|
||||
let isRefreshing = false;
|
||||
let requestsQueue: Array<(token: string) => void> = [];
|
||||
|
||||
// 定义错误类型
|
||||
interface ApiError {
|
||||
code: number;
|
||||
msg: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// 封装axios请求
|
||||
class HttpRequest {
|
||||
private baseUrl: string;
|
||||
private instanceMap: Map<string, AxiosInstance> = new Map();
|
||||
private instance: AxiosInstance;
|
||||
|
||||
constructor(baseUrl?: string) {
|
||||
this.baseUrl = baseUrl || "";
|
||||
}
|
||||
|
||||
// 获取基础配置
|
||||
private getBaseConfig(): AxiosRequestConfig {
|
||||
return {
|
||||
baseURL: this.baseUrl || baseUrl,
|
||||
constructor(externalBaseUrl?: string) {
|
||||
this.instance = axios.create({
|
||||
baseURL: externalBaseUrl || baseUrl,
|
||||
timeout: 50 * 1000,
|
||||
headers: {
|
||||
"Content-Type": "application/json;charset=utf-8",
|
||||
},
|
||||
};
|
||||
headers: { "Content-Type": "application/json;charset=utf-8" },
|
||||
});
|
||||
|
||||
this.setupInterceptors();
|
||||
}
|
||||
|
||||
// 创建 axios 实例
|
||||
private createAxiosInstance(config: AxiosRequestConfig): AxiosInstance {
|
||||
return axios.create(config);
|
||||
// --- Token 管理方法 ---
|
||||
private getAccessToken() { return localStorage.getItem("accessToken"); }
|
||||
private getRefreshToken() { return localStorage.getItem("refreshToken"); }
|
||||
private clearTokens() {
|
||||
localStorage.removeItem("accessToken");
|
||||
localStorage.removeItem("refreshToken");
|
||||
// 这里可以触发跳转登录逻辑,例如:router.push('/login')
|
||||
}
|
||||
private setTokens(data: any) {
|
||||
localStorage.setItem("accessToken", data.accessToken);
|
||||
localStorage.setItem("refreshToken", data.refreshToken);
|
||||
}
|
||||
|
||||
// 设置请求和响应拦截器
|
||||
private setupInterceptors(instance: AxiosInstance, url: string) {
|
||||
const { refreshToken, getAccessToken, clearTokens } = useTokenRefresh(this.baseUrl);
|
||||
|
||||
private setupInterceptors() {
|
||||
// 请求拦截器
|
||||
instance.interceptors.request.use(
|
||||
(config: AxiosRequestConfig) => {
|
||||
// 添加 token
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
config.headers = config.headers || {};
|
||||
this.instance.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = this.getAccessToken();
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error: any) => {
|
||||
console.error('Request error:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// 响应拦截器
|
||||
instance.interceptors.response.use(
|
||||
async (response: AxiosResponse<ApiResponse>) => {
|
||||
const { data: responseData, status } = response;
|
||||
this.instance.interceptors.response.use(
|
||||
async (response: AxiosResponse) => {
|
||||
const { data: res, config: originalRequest } = response;
|
||||
|
||||
switch (responseData.code) {
|
||||
case 0:
|
||||
// 成功
|
||||
return Promise.resolve(responseData);
|
||||
// 业务成功直接返回数据
|
||||
if (res.code === 0) return res;
|
||||
|
||||
case 302:
|
||||
// 重定向
|
||||
if (responseData.msg) {
|
||||
window.location.href = responseData.msg;
|
||||
}
|
||||
return Promise.reject(responseData);
|
||||
// 重点:401 未授权处理
|
||||
if (res.code === 401) {
|
||||
|
||||
case 401:
|
||||
// 未授权,需要刷新token
|
||||
if (
|
||||
originalRequest &&
|
||||
!originalRequest._retry &&
|
||||
!/\/auth\/refresh$/i.test(originalRequest.url || "")
|
||||
) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
// 刷新 token
|
||||
await refreshToken();
|
||||
|
||||
// 重试原请求
|
||||
const newToken = getAccessToken();
|
||||
if (newToken) {
|
||||
originalRequest.headers = originalRequest.headers || {};
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
}
|
||||
|
||||
return instance(originalRequest);
|
||||
} catch (refreshErr) {
|
||||
// 刷新失败,清理 token 并跳转登录
|
||||
clearTokens();
|
||||
// 可以在这里触发跳转到登录页
|
||||
// router.push('/login');
|
||||
console.error('Token refresh failed:', refreshErr);
|
||||
return Promise.reject(refreshErr);
|
||||
}
|
||||
}
|
||||
return Promise.reject(responseData);
|
||||
|
||||
default:
|
||||
// 其他业务错误
|
||||
ElNotification({
|
||||
title:'提示',
|
||||
message: responseData.msg || '请求失败',
|
||||
type: 'error'
|
||||
})
|
||||
return Promise.reject(responseData);
|
||||
}
|
||||
},
|
||||
(error: any) => {
|
||||
// 网络错误或其他错误
|
||||
console.error('Response error:', error);
|
||||
|
||||
// 判断错误类型
|
||||
if (error.response) {
|
||||
// 服务器返回错误状态码
|
||||
const { status, data } = error.response;
|
||||
|
||||
if (status === 401) {
|
||||
// 未授权,清理 token
|
||||
const { clearTokens } = useTokenRefresh(this.baseUrl);
|
||||
clearTokens();
|
||||
} else if (status >= 500) {
|
||||
// 服务器错误
|
||||
return Promise.reject({
|
||||
code: status,
|
||||
msg: '服务器内部错误,请稍后重试',
|
||||
} as ApiError);
|
||||
// 如果已经在刷新中了,将请求挂起并加入队列
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve) => {
|
||||
requestsQueue.push((token: string) => {
|
||||
originalRequest.headers!.Authorization = `Bearer ${token}`;
|
||||
resolve(this.instance(originalRequest));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 开始刷新流程
|
||||
isRefreshing = true;
|
||||
const rToken = this.getRefreshToken();
|
||||
|
||||
if (!rToken) {
|
||||
this.clearTokens();
|
||||
return Promise.reject(res);
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用一个干净的 axios 实例去发刷新请求,避免拦截器死循环
|
||||
const refreshRes = await axios.post(`${this.instance.defaults.baseURL}/auth/refresh`, {
|
||||
refreshToken: rToken
|
||||
});
|
||||
|
||||
const newToken = refreshRes.data.data.accessToken;
|
||||
this.setTokens(refreshRes.data.data);
|
||||
|
||||
// 刷新成功:释放队列
|
||||
requestsQueue.forEach(callback => callback(newToken));
|
||||
requestsQueue = [];
|
||||
isRefreshing = false;
|
||||
|
||||
// 重试本次请求
|
||||
originalRequest.headers!.Authorization = `Bearer ${newToken}`;
|
||||
return this.instance(originalRequest);
|
||||
|
||||
} catch (error) {
|
||||
// 刷新失败:清理并彻底报错
|
||||
isRefreshing = false;
|
||||
requestsQueue = [];
|
||||
this.clearTokens();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
} else if (error.request) {
|
||||
// 网络错误
|
||||
return Promise.reject({
|
||||
code: -1,
|
||||
msg: '网络连接失败,请检查网络',
|
||||
} as ApiError);
|
||||
}
|
||||
|
||||
return Promise.reject({
|
||||
code: -1,
|
||||
msg: error.message || '请求失败',
|
||||
} as ApiError);
|
||||
// 其它业务错误
|
||||
ElNotification.error({ title: '提示', message: res.msg || '服务异常' });
|
||||
return Promise.reject(res);
|
||||
},
|
||||
(error) => {
|
||||
// 网络层错误处理
|
||||
if (error.response?.status === 401) {
|
||||
this.clearTokens();
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 获取实例的缓存键
|
||||
private getInstanceKey(baseURL: string, timeout: number): string {
|
||||
return `${baseURL || this.baseUrl}__${timeout}`;
|
||||
}
|
||||
|
||||
// 获取 axios 实例
|
||||
public getInstance(config: AxiosRequestConfig = {}): AxiosInstance {
|
||||
const baseURL = config.baseURL || this.baseUrl || baseUrl;
|
||||
const timeout = config.timeout || 50 * 1000;
|
||||
const key = this.getInstanceKey(baseURL, timeout);
|
||||
|
||||
if (this.instanceMap.has(key)) {
|
||||
return this.instanceMap.get(key)!;
|
||||
}
|
||||
|
||||
const instanceConfig = {
|
||||
...this.getBaseConfig(),
|
||||
...config,
|
||||
};
|
||||
|
||||
const instance = this.createAxiosInstance(instanceConfig);
|
||||
this.setupInterceptors(instance, baseURL);
|
||||
|
||||
this.instanceMap.set(key, instance);
|
||||
return instance;
|
||||
}
|
||||
|
||||
// 通用请求方法
|
||||
public request<T = any>(config: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
// 检查是否应该使用 Mock 数据
|
||||
// 检查是否应该使用 Mock 数据
|
||||
const requestUrl = config.url || '';
|
||||
|
||||
// 优先使用请求 URL(通常是相对路径,如 /api/menus)
|
||||
@@ -211,14 +139,10 @@ class HttpRequest {
|
||||
}
|
||||
}
|
||||
|
||||
const instance = this.getInstance(config);
|
||||
return instance(config).catch((error) => {
|
||||
// 统一错误处理
|
||||
throw error;
|
||||
}) as Promise<ApiResponse<T>>;
|
||||
return this.instance(config) as unknown as Promise<ApiResponse<T>>;
|
||||
}
|
||||
|
||||
// GET 请求
|
||||
// GET 请求
|
||||
public get<T = any>(url: string, params?: any, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
return this.request<T>({
|
||||
url,
|
||||
@@ -258,7 +182,6 @@ class HttpRequest {
|
||||
}
|
||||
}
|
||||
|
||||
// 创建实例
|
||||
const httpRequest = new HttpRequest(baseUrl);
|
||||
|
||||
// 导出方法
|
||||
@@ -272,5 +195,4 @@ export const request = {
|
||||
delete: <T = any>(url: string, config?: AxiosRequestConfig) =>
|
||||
httpRequest.delete<T>(url, config),
|
||||
};
|
||||
|
||||
export default request;
|
||||
export default httpRequest;
|
||||
Reference in New Issue
Block a user