style:新增登录界面 完善api请求逻辑

This commit is contained in:
liangdong
2025-12-30 21:07:45 +08:00
parent 1cc15fc76d
commit bd2a300f55
9 changed files with 409 additions and 253 deletions

2
components.d.ts vendored
View File

@@ -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']

View File

@@ -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
View File

@@ -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

View File

@@ -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) => {
// 如果是水平模式(顶部菜单),触发选中事件

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;
}
}
}
}

View File

@@ -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;

View File

@@ -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;
// 重点401 未授权处理
if (res.code === 401) {
case 302:
// 重定向
if (responseData.msg) {
window.location.href = responseData.msg;
}
return Promise.reject(responseData);
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
@@ -210,15 +138,11 @@ 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;