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']
|
CardItem: typeof import('./src/components/cardItem/index.vue')['default']
|
||||||
ElAside: typeof import('element-plus/es')['ElAside']
|
ElAside: typeof import('element-plus/es')['ElAside']
|
||||||
ElButton: typeof import('element-plus/es')['ElButton']
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
|
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||||
ElDatePick: typeof import('element-plus/es')['ElDatePick']
|
ElDatePick: typeof import('element-plus/es')['ElDatePick']
|
||||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||||
@@ -27,6 +28,7 @@ declare module 'vue' {
|
|||||||
ElHeader: typeof import('element-plus/es')['ElHeader']
|
ElHeader: typeof import('element-plus/es')['ElHeader']
|
||||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||||
ElInput: typeof import('element-plus/es')['ElInput']
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
|
ElLink: typeof import('element-plus/es')['ElLink']
|
||||||
ElMain: typeof import('element-plus/es')['ElMain']
|
ElMain: typeof import('element-plus/es')['ElMain']
|
||||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
"@micro-zoe/micro-app": "^1.0.0-rc.28",
|
"@micro-zoe/micro-app": "^1.0.0-rc.28",
|
||||||
"element-plus": "^2.13.0",
|
"element-plus": "^2.13.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
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':
|
'@micro-zoe/micro-app':
|
||||||
specifier: ^1.0.0-rc.28
|
specifier: ^1.0.0-rc.28
|
||||||
version: 1.0.0-rc.28
|
version: 1.0.0-rc.28
|
||||||
|
|||||||
@@ -9,25 +9,19 @@
|
|||||||
@select="handleMenuSelect"
|
@select="handleMenuSelect"
|
||||||
router
|
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">
|
<el-sub-menu v-if="item.children && item.children.length > 0">
|
||||||
<template #title>
|
<template #title>
|
||||||
<el-icon>
|
<el-icon v-if="item.meta?.icon"><component :is="item.meta.icon" /></el-icon>
|
||||||
<location />
|
|
||||||
</el-icon>
|
|
||||||
<span>{{ item.meta.title }}</span>
|
<span>{{ item.meta.title }}</span>
|
||||||
</template>
|
</template>
|
||||||
<el-menu-item :index="`${item.path}/${row.path}`" v-for="(row,key) in item.children" :key="key">
|
<el-menu-item :index="resolvePath(item.path, row.path)" v-for="(row,key) in item.children" :key="resolvePath(item.path, row.path)">
|
||||||
<el-icon>
|
<el-icon v-if="row.meta?.icon"><component :is="row.meta.icon" /></el-icon>
|
||||||
<location />
|
|
||||||
</el-icon>
|
|
||||||
<template #title>{{ row.meta.title }}</template>
|
<template #title>{{ row.meta.title }}</template>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
</el-sub-menu>
|
</el-sub-menu>
|
||||||
<el-menu-item v-else :index="item.path">
|
<el-menu-item v-else :index="item.path">
|
||||||
<el-icon>
|
<el-icon v-if="item.meta?.icon"><component :is="item.meta.icon" /></el-icon>
|
||||||
<location />
|
|
||||||
</el-icon>
|
|
||||||
<template #title>{{ item.meta.title }}</template>
|
<template #title>{{ item.meta.title }}</template>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
</template>
|
</template>
|
||||||
@@ -37,6 +31,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Location } from '@element-plus/icons-vue'
|
import { Location } from '@element-plus/icons-vue'
|
||||||
defineOptions({ name: "standardMenu" })
|
defineOptions({ name: "standardMenu" })
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
const {mode="vertical",menuList,isCollapse,activeMenu} = defineProps<{
|
const {mode="vertical",menuList,isCollapse,activeMenu} = defineProps<{
|
||||||
mode?: 'vertical' | 'horizontal'
|
mode?: 'vertical' | 'horizontal'
|
||||||
@@ -51,9 +46,30 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const activeIndex = computed(() => {
|
const activeIndex = computed(() => {
|
||||||
// 如果传入了 activeMenu,使用它;否则使用默认值
|
// 如果传入了 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) => {
|
const handleMenuSelect = (index: string) => {
|
||||||
// 如果是水平模式(顶部菜单),触发选中事件
|
// 如果是水平模式(顶部菜单),触发选中事件
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ import en from "element-plus/es/locale/lang/en";
|
|||||||
import Directives from '@/utils/directives';
|
import Directives from '@/utils/directives';
|
||||||
import '@/styles/common.scss';
|
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 pinia = createPinia();
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ defineOptions({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
// 响应式断点(小屏阈值,小于此值视为小屏)
|
// 响应式断点(小屏阈值,小于此值视为小屏)
|
||||||
const BREAKPOINT = 1024;
|
const BREAKPOINT = 1024;
|
||||||
@@ -158,7 +159,16 @@ const handleSideMenuSelect = (menuPath: string) => {
|
|||||||
|
|
||||||
// 初始化:默认选中第一个菜单
|
// 初始化:默认选中第一个菜单
|
||||||
onMounted(() => {
|
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];
|
const firstMenu = topLevelMenuList.value[0];
|
||||||
if (firstMenu && firstMenu.path) {
|
if (firstMenu && firstMenu.path) {
|
||||||
selectedTopMenu.value = firstMenu.path;
|
selectedTopMenu.value = firstMenu.path;
|
||||||
|
|||||||
@@ -1,48 +1,89 @@
|
|||||||
<template>
|
<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">
|
<div class="login-box">
|
||||||
<h2 class="login-title">系统登录</h2>
|
<h2 class="welcome-title">欢迎回来</h2>
|
||||||
|
<p class="welcome-sub">请登录您的账号以继续</p>
|
||||||
|
|
||||||
<el-form
|
<el-form
|
||||||
ref="loginFormRef"
|
|
||||||
:model="loginForm"
|
:model="loginForm"
|
||||||
:rules="loginRules"
|
label-position="top"
|
||||||
label-width="0"
|
|
||||||
class="login-form"
|
class="login-form"
|
||||||
|
ref="loginFormRef"
|
||||||
>
|
>
|
||||||
<el-form-item prop="username">
|
<el-form-item label="账号/邮箱" class="account-item">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="loginForm.username"
|
v-model="loginForm.username"
|
||||||
placeholder="请输入用户名"
|
placeholder="admin@figmamake.com"
|
||||||
size="large"
|
:prefix-icon="Message"
|
||||||
prefix-icon="User"
|
|
||||||
clearable
|
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</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
|
<el-input
|
||||||
v-model="loginForm.password"
|
v-model="loginForm.password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="请输入密码"
|
|
||||||
size="large"
|
|
||||||
prefix-icon="Lock"
|
|
||||||
show-password
|
show-password
|
||||||
clearable
|
placeholder="••••••••"
|
||||||
@keyup.enter="handleLogin"
|
:prefix-icon="Lock"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
|
||||||
<el-button
|
<div class="form-options">
|
||||||
type="primary"
|
<el-checkbox v-model="loginForm.remember">
|
||||||
size="large"
|
<span class="checkbox-text">记住此设备</span>
|
||||||
:loading="loading"
|
</el-checkbox>
|
||||||
class="login-button"
|
</div>
|
||||||
@click="handleLogin"
|
|
||||||
>
|
<el-button type="primary" class="login-btn" @click="handleLogin">
|
||||||
{{ loading ? '登录中...' : '登录' }}
|
登录系统 <el-icon class="el-icon--right"><Right /></el-icon>
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,6 +93,7 @@
|
|||||||
import { reactive, ref } from "vue";
|
import { reactive, ref } from "vue";
|
||||||
import { useRouter, useRoute } from "vue-router";
|
import { useRouter, useRoute } from "vue-router";
|
||||||
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
|
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
|
||||||
|
import { Message, Lock, Right } from "@element-plus/icons-vue";
|
||||||
import { login } from "@/api";
|
import { login } from "@/api";
|
||||||
import { useUserStore } from "@/store";
|
import { useUserStore } from "@/store";
|
||||||
import useTokenRefresh from "@/hooks/useTokenRefresh";
|
import useTokenRefresh from "@/hooks/useTokenRefresh";
|
||||||
@@ -74,7 +116,12 @@ const loginForm = reactive({
|
|||||||
const loginRules: FormRules = {
|
const loginRules: FormRules = {
|
||||||
username: [
|
username: [
|
||||||
{ required: true, message: "请输入用户名", trigger: "blur" },
|
{ required: true, message: "请输入用户名", trigger: "blur" },
|
||||||
{ min: 3, max: 20, message: "用户名长度在 3 到 20 个字符", trigger: "blur" },
|
{
|
||||||
|
min: 3,
|
||||||
|
max: 20,
|
||||||
|
message: "用户名长度在 3 到 20 个字符",
|
||||||
|
trigger: "blur",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
password: [
|
password: [
|
||||||
{ required: true, message: "请输入密码", trigger: "blur" },
|
{ required: true, message: "请输入密码", trigger: "blur" },
|
||||||
@@ -96,7 +143,8 @@ const handleLogin = async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.code === 0 && response.data) {
|
if (response.code === 0 && response.data) {
|
||||||
const { accessToken, refreshToken, expiresIn, userInfo } = response.data;
|
const { accessToken, refreshToken, expiresIn, userInfo } =
|
||||||
|
response.data;
|
||||||
|
|
||||||
// 保存 token
|
// 保存 token
|
||||||
setTokens({
|
setTokens({
|
||||||
@@ -128,38 +176,189 @@ const handleLogin = async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<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;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
border-radius: 8px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.logo-text {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
span {
|
||||||
|
color: $primary-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.login-container {
|
.content-wrapper {
|
||||||
width: 100%;
|
z-index: 2;
|
||||||
max-width: 400px;
|
.main-title {
|
||||||
padding: 20px;
|
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;
|
||||||
|
|
||||||
.login-box {
|
.login-box {
|
||||||
background: #fff;
|
width: 100%;
|
||||||
border-radius: 8px;
|
max-width: 440px;
|
||||||
padding: 40px;
|
padding: 0 40px;
|
||||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
|
||||||
|
|
||||||
.login-title {
|
.welcome-title {
|
||||||
text-align: center;
|
font-size: 32px;
|
||||||
margin-bottom: 30px;
|
margin-bottom: 8px;
|
||||||
font-size: 24px;
|
}
|
||||||
font-weight: 600;
|
.welcome-sub {
|
||||||
color: #333;
|
color: $text-secondary;
|
||||||
|
margin-bottom: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-form {
|
.login-form {
|
||||||
.login-button {
|
// 深度作用选择器统一处理 Element Plus 内部样式
|
||||||
width: 100%;
|
:deep(.el-form-item__label) {
|
||||||
margin-top: 10px;
|
font-weight: 500;
|
||||||
|
color: #666;
|
||||||
|
padding-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
: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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,7 @@
|
|||||||
<!-- 顶部的tabs菜单 -->
|
<!-- 顶部的tabs菜单 -->
|
||||||
<div class="organization-tabs">
|
<div class="organization-tabs">
|
||||||
<stageBreadcrumbs title="组织管理">
|
<stageBreadcrumbs title="组织管理">
|
||||||
<template #content>
|
<template #content> 内容占位 </template>
|
||||||
内容占位
|
|
||||||
</template>
|
|
||||||
<template #action>
|
<template #action>
|
||||||
<el-button type="primary" :icon="Plus" plain>新增集团</el-button>
|
<el-button type="primary" :icon="Plus" plain>新增集团</el-button>
|
||||||
</template>
|
</template>
|
||||||
@@ -53,7 +51,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Plus, Search } from "@element-plus/icons-vue";
|
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" });
|
defineOptions({ name: "Organization" });
|
||||||
|
|
||||||
const addValue = ref("");
|
const addValue = ref("");
|
||||||
@@ -61,7 +59,6 @@ const search = ref("");
|
|||||||
|
|
||||||
const activeName = ref("first");
|
const activeName = ref("first");
|
||||||
|
|
||||||
|
|
||||||
interface Tree {
|
interface Tree {
|
||||||
label: string;
|
label: string;
|
||||||
children?: Tree[];
|
children?: Tree[];
|
||||||
@@ -135,6 +132,7 @@ const defaultProps = {
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@use "sass:math";
|
||||||
.mj-organization {
|
.mj-organization {
|
||||||
.mj-organization-card {
|
.mj-organization-card {
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
@@ -145,7 +143,6 @@ const defaultProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.organization-tabs {
|
.organization-tabs {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.organization-content {
|
.organization-content {
|
||||||
@@ -168,7 +165,7 @@ const defaultProps = {
|
|||||||
.org-tree-list {
|
.org-tree-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: $mj-padding-standard/2 $mj-padding-standard;
|
padding: math.div($mj-padding-standard, 2) $mj-padding-standard;
|
||||||
}
|
}
|
||||||
.org-bottom-add {
|
.org-bottom-add {
|
||||||
border-top: 1px solid #f1f5f9;
|
border-top: 1px solid #f1f5f9;
|
||||||
|
|||||||
@@ -1,197 +1,125 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
|
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||||
import useTokenRefresh from "@/hooks/useTokenRefresh";
|
import { ElNotification } from 'element-plus';
|
||||||
import 'element-plus/es/components/notification/style/css'
|
|
||||||
import { ElNotification } from 'element-plus'
|
|
||||||
import { getMockData, shouldUseMock } from '@/mock' //mock数据信息
|
import { getMockData, shouldUseMock } from '@/mock' //mock数据信息
|
||||||
|
|
||||||
|
|
||||||
const baseUrl = import.meta.env.VITE_APP_BASE_API;
|
const baseUrl = import.meta.env.VITE_APP_BASE_API;
|
||||||
|
|
||||||
// 定义响应数据类型
|
// 1. 锁和队列定义在类外部,确保全局唯一
|
||||||
interface ApiResponse<T = any> {
|
let isRefreshing = false;
|
||||||
code: number;
|
let requestsQueue: Array<(token: string) => void> = [];
|
||||||
data: T;
|
|
||||||
msg: string;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 定义错误类型
|
|
||||||
interface ApiError {
|
|
||||||
code: number;
|
|
||||||
msg: string;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 封装axios请求
|
|
||||||
class HttpRequest {
|
class HttpRequest {
|
||||||
private baseUrl: string;
|
private instance: AxiosInstance;
|
||||||
private instanceMap: Map<string, AxiosInstance> = new Map();
|
|
||||||
|
|
||||||
constructor(baseUrl?: string) {
|
constructor(externalBaseUrl?: string) {
|
||||||
this.baseUrl = baseUrl || "";
|
this.instance = axios.create({
|
||||||
}
|
baseURL: externalBaseUrl || baseUrl,
|
||||||
|
|
||||||
// 获取基础配置
|
|
||||||
private getBaseConfig(): AxiosRequestConfig {
|
|
||||||
return {
|
|
||||||
baseURL: this.baseUrl || baseUrl,
|
|
||||||
timeout: 50 * 1000,
|
timeout: 50 * 1000,
|
||||||
headers: {
|
headers: { "Content-Type": "application/json;charset=utf-8" },
|
||||||
"Content-Type": "application/json;charset=utf-8",
|
});
|
||||||
},
|
|
||||||
};
|
this.setupInterceptors();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建 axios 实例
|
// --- Token 管理方法 ---
|
||||||
private createAxiosInstance(config: AxiosRequestConfig): AxiosInstance {
|
private getAccessToken() { return localStorage.getItem("accessToken"); }
|
||||||
return axios.create(config);
|
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() {
|
||||||
private setupInterceptors(instance: AxiosInstance, url: string) {
|
|
||||||
const { refreshToken, getAccessToken, clearTokens } = useTokenRefresh(this.baseUrl);
|
|
||||||
|
|
||||||
// 请求拦截器
|
// 请求拦截器
|
||||||
instance.interceptors.request.use(
|
this.instance.interceptors.request.use(
|
||||||
(config: AxiosRequestConfig) => {
|
(config) => {
|
||||||
// 添加 token
|
const token = this.getAccessToken();
|
||||||
const token = getAccessToken();
|
if (token && config.headers) {
|
||||||
if (token) {
|
|
||||||
config.headers = config.headers || {};
|
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
(error: any) => {
|
(error) => Promise.reject(error)
|
||||||
console.error('Request error:', error);
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 响应拦截器
|
// 响应拦截器
|
||||||
instance.interceptors.response.use(
|
this.instance.interceptors.response.use(
|
||||||
async (response: AxiosResponse<ApiResponse>) => {
|
async (response: AxiosResponse) => {
|
||||||
const { data: responseData, status } = response;
|
const { data: res, config: originalRequest } = response;
|
||||||
|
|
||||||
switch (responseData.code) {
|
// 业务成功直接返回数据
|
||||||
case 0:
|
if (res.code === 0) return res;
|
||||||
// 成功
|
|
||||||
return Promise.resolve(responseData);
|
|
||||||
|
|
||||||
case 302:
|
// 重点:401 未授权处理
|
||||||
// 重定向
|
if (res.code === 401) {
|
||||||
if (responseData.msg) {
|
|
||||||
window.location.href = responseData.msg;
|
// 如果已经在刷新中了,将请求挂起并加入队列
|
||||||
|
if (isRefreshing) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
requestsQueue.push((token: string) => {
|
||||||
|
originalRequest.headers!.Authorization = `Bearer ${token}`;
|
||||||
|
resolve(this.instance(originalRequest));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return Promise.reject(responseData);
|
|
||||||
|
|
||||||
case 401:
|
// 开始刷新流程
|
||||||
// 未授权,需要刷新token
|
isRefreshing = true;
|
||||||
if (
|
const rToken = this.getRefreshToken();
|
||||||
originalRequest &&
|
|
||||||
!originalRequest._retry &&
|
if (!rToken) {
|
||||||
!/\/auth\/refresh$/i.test(originalRequest.url || "")
|
this.clearTokens();
|
||||||
) {
|
return Promise.reject(res);
|
||||||
originalRequest._retry = true;
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 刷新 token
|
// 使用一个干净的 axios 实例去发刷新请求,避免拦截器死循环
|
||||||
await refreshToken();
|
const refreshRes = await axios.post(`${this.instance.defaults.baseURL}/auth/refresh`, {
|
||||||
|
refreshToken: rToken
|
||||||
|
});
|
||||||
|
|
||||||
// 重试原请求
|
const newToken = refreshRes.data.data.accessToken;
|
||||||
const newToken = getAccessToken();
|
this.setTokens(refreshRes.data.data);
|
||||||
if (newToken) {
|
|
||||||
originalRequest.headers = originalRequest.headers || {};
|
// 刷新成功:释放队列
|
||||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return instance(originalRequest);
|
// 其它业务错误
|
||||||
} catch (refreshErr) {
|
ElNotification.error({ title: '提示', message: res.msg || '服务异常' });
|
||||||
// 刷新失败,清理 token 并跳转登录
|
return Promise.reject(res);
|
||||||
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) => {
|
(error) => {
|
||||||
// 网络错误或其他错误
|
// 网络层错误处理
|
||||||
console.error('Response error:', error);
|
if (error.response?.status === 401) {
|
||||||
|
this.clearTokens();
|
||||||
// 判断错误类型
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
} else if (error.request) {
|
return Promise.reject(error);
|
||||||
// 网络错误
|
|
||||||
return Promise.reject({
|
|
||||||
code: -1,
|
|
||||||
msg: '网络连接失败,请检查网络',
|
|
||||||
} as ApiError);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.reject({
|
|
||||||
code: -1,
|
|
||||||
msg: error.message || '请求失败',
|
|
||||||
} as ApiError);
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取实例的缓存键
|
|
||||||
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>> {
|
public request<T = any>(config: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||||
// 检查是否应该使用 Mock 数据
|
// 检查是否应该使用 Mock 数据
|
||||||
const requestUrl = config.url || '';
|
const requestUrl = config.url || '';
|
||||||
@@ -211,11 +139,7 @@ class HttpRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const instance = this.getInstance(config);
|
return this.instance(config) as unknown as Promise<ApiResponse<T>>;
|
||||||
return instance(config).catch((error) => {
|
|
||||||
// 统一错误处理
|
|
||||||
throw error;
|
|
||||||
}) as Promise<ApiResponse<T>>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET 请求
|
// GET 请求
|
||||||
@@ -258,7 +182,6 @@ class HttpRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建实例
|
|
||||||
const httpRequest = new HttpRequest(baseUrl);
|
const httpRequest = new HttpRequest(baseUrl);
|
||||||
|
|
||||||
// 导出方法
|
// 导出方法
|
||||||
@@ -272,5 +195,4 @@ export const request = {
|
|||||||
delete: <T = any>(url: string, config?: AxiosRequestConfig) =>
|
delete: <T = any>(url: string, config?: AxiosRequestConfig) =>
|
||||||
httpRequest.delete<T>(url, config),
|
httpRequest.delete<T>(url, config),
|
||||||
};
|
};
|
||||||
|
export default httpRequest;
|
||||||
export default request;
|
|
||||||
Reference in New Issue
Block a user