fix:联调登录接口

This commit is contained in:
unknown
2025-12-31 18:57:06 +08:00
parent bd2a300f55
commit f22b0dfcbc
27 changed files with 1514 additions and 226 deletions

16
components.d.ts vendored
View File

@@ -12,12 +12,19 @@ export {}
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
CardItem: typeof import('./src/components/cardItem/index.vue')['default'] CardItem: typeof import('./src/components/cardItem/index.vue')['default']
CommonFilter: typeof import('./src/components/commonFilter/index.vue')['default']
ElAside: typeof import('element-plus/es')['ElAside'] ElAside: typeof import('element-plus/es')['ElAside']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElBadge: typeof import('element-plus/es')['ElBadge']
ElButton: typeof import('element-plus/es')['ElButton'] ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCol: typeof import('element-plus/es')['ElCol']
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']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog'] ElDialog: typeof import('element-plus/es')['ElDialog']
ElDrawer: typeof import('element-plus/es')['ElDrawer'] ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElDropdown: typeof import('element-plus/es')['ElDropdown'] ElDropdown: typeof import('element-plus/es')['ElDropdown']
@@ -32,11 +39,20 @@ declare module 'vue' {
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']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar'] ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElTabPane: typeof import('element-plus/es')['ElTabPane'] ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs'] ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElTree: typeof import('element-plus/es')['ElTree'] ElTree: typeof import('element-plus/es')['ElTree']
OverflowTabs: typeof import('./src/components/overflowTabs/index.vue')['default']
PageForm: typeof import('./src/components/pageForm/index.vue')['default'] PageForm: typeof import('./src/components/pageForm/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']

View File

@@ -1,3 +1,9 @@
/**
* 项目配置文件
* VITE_PROJECT_PREFIX 项目前缀,默认是'/'
* VITE_PUBLIC_PATH 当前项目的basePath默认是'/'
* VITE_APP_BASE_API 请求接口地址域名
* */
export const VITE_PROJECT_PREFIX = '/'; export const VITE_PROJECT_PREFIX = '/';
export const VITE_PUBLIC_PATH = './'; export const VITE_PUBLIC_PATH = '/';
export const VITE_APP_BASE_API = 'http://api.test.com'; export const VITE_APP_BASE_API = 'https://mversion-dev.zzmjart.com/api' //'http://192.168.42.106';

View File

@@ -1,18 +1,17 @@
import request from '@/request'; import request from '@/request';
// 设置请求的参数信息
export const getUserList = (params?: any) => {
return request.get('/api/user/list', params);
};
// 获取路由菜单数据 // 获取路由菜单数据
export const getRouteMenus = () => { export const getRouteMenus = () => {
return request.get('/api/menus'); return request.get('/auth/v1/backend/menu');
}; };
// 登录接口 // 登录接口
export const login = (data: { username: string; password: string }) => { export const login = (data: { username: string; password: string }) => {
return request.post('/api/auth/login', data); return request.post('/auth/oauth2/token', data,{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
});
}; };
// 获取用户信息 // 获取用户信息

BIN
src/assets/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,70 @@
<template>
<div class="mj-filter-group">
<div class="mj-icon-container">
<el-popover
ref="filterPopover"
trigger="click"
popper-class="filter-popper"
placement="bottom-end"
:teleported="true"
>
<template #reference>
<div class="mj-icon-item" title="筛选">
<el-icon><Filter /></el-icon>
</div>
</template>
<div class="filter-container">
<slot></slot>
</div>
</el-popover>
<div class="mj-icon-item" title="下载" @click="$emit('download')">
<el-icon><Download /></el-icon>
</div>
</div>
</div>
</template>
<script setup>
import { Filter, Download } from "@element-plus/icons-vue";
const filterPopover = ref(null);
// 定义事件:重置、应用、下载
defineEmits(["download"]);
defineExpose({
close() {
filterPopover.value?.hide()
},
});
</script>
<style lang="scss" scoped>
.mj-icon-container {
display: inline-flex;
align-items: center;
padding: 2px 4px;
border-radius: 4px;
background-color: #fff;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
.mj-icon-item {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
font-size: 18px;
color: #606266;
cursor: pointer;
transition: all 0.2s;
border-radius: 4px;
&:hover {
background-color: #f5f7fa;
color: #409eff;
}
}
}
</style>

View File

@@ -0,0 +1,220 @@
<template>
<div class="tabs-outer-container" ref="containerRef" :style="{ height: height + 'px' }">
<div class="tabs-wrapper">
<div
v-for="(item, index) in visibleItems"
:key="item.id"
class="tab-item"
:class="{ active: modelValue === item.id }"
@click="$emit('update:modelValue', item.id)"
>
<span class="tab-text">{{ item.label }}</span>
</div>
<el-dropdown
v-if="hiddenItems.length > 0"
trigger="click"
@command="handleCommand"
class="more-dropdown"
>
<div class="tab-item more-trigger">
<span>更多</span>
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="item in hiddenItems"
:key="item.id"
:command="item.id"
>
<span :class="{ 'is-active-item-overflow-tabs': modelValue === item.id }">{{ item.label }}</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<div class="active-bar" :style="activeBarStyle"></div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, computed, nextTick, watch } from "vue";
import { ArrowDown } from "@element-plus/icons-vue";
const props = defineProps({
modelValue: [String, Number],
items: {
type: Array,
default: () => [],
},
height: {
type: Number,
default: 32,
},
});
const emit = defineEmits(["update:modelValue"]);
const containerRef = ref(null);
const splitIndex = ref(props.items.length);
let timer = null;
const activeBarStyle = ref({
width: "0px",
left: "0px",
opacity: 0,
});
const visibleItems = computed(() => props.items.slice(0, splitIndex.value));
const hiddenItems = computed(() => props.items.slice(splitIndex.value));
const isHiddenActive = computed(() => {
return hiddenItems.value.some((item) => item.id === props.modelValue);
});
const updateActiveBar = async () => {
await nextTick();
if (!containerRef.value) return;
// 1. 检查激活项是否在可见区域
const activeIndex = visibleItems.value.findIndex((item) => item.id === props.modelValue);
if (activeIndex >= 0) {
// 激活项在可见区域:计算位置并显示下划线
const tabItems = containerRef.value.querySelectorAll(".tab-item:not(.more-trigger)");
const activeElement = tabItems[activeIndex];
if (activeElement) {
const rect = activeElement.getBoundingClientRect();
const containerRect = containerRef.value.getBoundingClientRect();
activeBarStyle.value = {
width: `${rect.width * 0.6}px`,
left: `${rect.left - containerRect.left + rect.width * 0.2}px`,
opacity: 1,
};
return;
}
}
// 2. 如果在隐藏区域或未找到,将下划线宽度设为 0
activeBarStyle.value = {
...activeBarStyle.value,
width: "0px",
opacity: 0,
};
};
const calculateLayout = () => {
if (!containerRef.value) return;
const containerWidth = Math.floor(containerRef.value.getBoundingClientRect().width);
const itemsNodes = containerRef.value.querySelectorAll(".tab-item:not(.more-trigger)");
const moreBtnWidth = 90;
let currentWidth = 0;
let newSplitIndex = props.items.length;
for (let i = 0; i < itemsNodes.length; i++) {
currentWidth += Math.ceil(itemsNodes[i].getBoundingClientRect().width) + 20;
if (currentWidth + moreBtnWidth > containerWidth) {
newSplitIndex = i;
break;
}
}
if (splitIndex.value !== newSplitIndex) {
splitIndex.value = newSplitIndex;
}
};
const debouncedCalc = () => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
calculateLayout();
updateActiveBar();
}, 100);
};
let resizeObserver = null;
onMounted(async () => {
await nextTick();
calculateLayout();
updateActiveBar();
resizeObserver = new ResizeObserver(() => debouncedCalc());
if (containerRef.value) resizeObserver.observe(containerRef.value);
});
onUnmounted(() => {
if (resizeObserver) resizeObserver.disconnect();
if (timer) clearTimeout(timer);
});
const handleCommand = (id) => emit("update:modelValue", id);
watch(() => props.modelValue, () => updateActiveBar());
watch(() => props.items, async () => {
splitIndex.value = props.items.length;
await nextTick();
calculateLayout();
updateActiveBar();
}, { deep: true });
</script>
<style scoped lang="scss">
.tabs-outer-container {
width: 100%;
overflow: hidden;
.tabs-wrapper {
display: flex;
align-items: center;
height: 100%;
position: relative;
user-select: none;
}
.tab-item {
height: 100%;
display: flex;
align-items: center;
padding: 0 20px;
cursor: pointer;
color: #606266;
font-size: 14px;
position: relative;
white-space: nowrap;
flex-shrink: 0;
transition: color 0.2s ease;
&.active {
color: #409eff;
font-weight: 600;
}
}
.more-trigger {
margin-left: auto;
border-left: 1px solid #f0f2f5;
// 选中更多中的数据时,仅文字和图标变蓝
&.is-more-active {
color: #409eff;
font-weight: 600;
}
}
.active-bar {
position: absolute;
height: 2px;
background-color: #409eff;
transition: left 0.3s cubic-bezier(0.4, 0, 0.2, 1),
width 0.3s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.2s;
bottom: 0;
pointer-events: none;
}
}
</style>

View File

@@ -26,10 +26,13 @@ const { title } = defineProps<{
justify-content: space-between; justify-content: space-between;
:deep(.mj-panel-title){ :deep(.mj-panel-title){
margin-bottom: 0; margin-bottom: 0;
flex-shrink: 0;
} }
.stage-breadcrumbs-content{ .stage-breadcrumbs-content{
flex: 1; flex: 1;
display: flex;
align-items: center;
&::before{ &::before{
content:''; content:'';
display: inline-block; display: inline-block;

View File

@@ -46,7 +46,7 @@ export const mockMenuData: MockMenuRoute[] = [
{ {
name: "clue", name: "clue",
path: "clue", path: "clue",
component: "@/pages/StageManage/organization/index.vue", component: "@/pages/BackBackstageManage/organization/index.vue",
meta: { meta: {
title: "线索管理", title: "线索管理",
icon: "", icon: "",
@@ -58,7 +58,7 @@ export const mockMenuData: MockMenuRoute[] = [
{ {
name: "customer", name: "customer",
path: "customer", path: "customer",
component: "@/pages/StageManage/organization/index.vue", component: "@/pages/BackstageManage/organization/index.vue",
meta: { meta: {
title: "客户管理", title: "客户管理",
icon: "", icon: "",
@@ -69,7 +69,7 @@ export const mockMenuData: MockMenuRoute[] = [
{ {
name: "studio", name: "studio",
path: "studio", path: "studio",
component: "@/pages/StageManage/organization/index.vue", component: "@/pages/BackstageManage/organization/index.vue",
meta: { meta: {
title: "游戏与工作室", title: "游戏与工作室",
icon: "", icon: "",
@@ -80,7 +80,7 @@ export const mockMenuData: MockMenuRoute[] = [
{ {
name: "businessmanage", name: "businessmanage",
path: "businessmanage", path: "businessmanage",
component: "@/pages/StageManage/organization/index.vue", component: "@/pages/BackstageManage/organization/index.vue",
meta: { meta: {
title: "商机管理", title: "商机管理",
icon: "", icon: "",
@@ -103,7 +103,7 @@ export const mockMenuData: MockMenuRoute[] = [
{ {
name: "income", name: "income",
path: "income", path: "income",
component: "@/pages/StageManage/organization/index.vue", component: "@/pages/BackstageManage/organization/index.vue",
meta: { meta: {
title: "收入合同", title: "收入合同",
icon: "", icon: "",
@@ -126,7 +126,7 @@ export const mockMenuData: MockMenuRoute[] = [
{ {
name: "requirement", name: "requirement",
path: "requirement", path: "requirement",
component: "@/pages/StageManage/organization/index.vue", component: "@/pages/BackstageManage/organization/index.vue",
meta: { meta: {
title: "需求管理", title: "需求管理",
icon: "", icon: "",
@@ -137,7 +137,7 @@ export const mockMenuData: MockMenuRoute[] = [
{ {
name: "projectmanage", name: "projectmanage",
path: "projectmanage", path: "projectmanage",
component: "@/pages/StageManage/organization/index.vue", component: "@/pages/BackstageManage/organization/index.vue",
meta: { meta: {
title: "项目管理", title: "项目管理",
icon: "", icon: "",
@@ -148,7 +148,7 @@ export const mockMenuData: MockMenuRoute[] = [
{ {
name: "task", name: "task",
path: "task", path: "task",
component: "@/pages/StageManage/organization/index.vue", component: "@/pages/BackstageManage/organization/index.vue",
meta: { meta: {
title: "任务管理", title: "任务管理",
icon: "", icon: "",
@@ -159,7 +159,7 @@ export const mockMenuData: MockMenuRoute[] = [
{ {
name: "work", name: "work",
path: "work", path: "work",
component: "@/pages/StageManage/organization/index.vue", component: "@/pages/BackstageManage/organization/index.vue",
meta: { meta: {
title: "工时管理", title: "工时管理",
icon: "", icon: "",
@@ -193,7 +193,7 @@ export const mockMenuData: MockMenuRoute[] = [
{ {
name: "resume", name: "resume",
path: "resume", path: "resume",
component: "@/pages/StageManage/organization/index.vue", component: "@/pages/BackstageManage/organization/index.vue",
meta: { meta: {
title: "流程管理", title: "流程管理",
icon: "", icon: "",
@@ -204,7 +204,7 @@ export const mockMenuData: MockMenuRoute[] = [
{ {
name: "push", name: "push",
path: "push", path: "push",
component: "@/pages/StageManage/organization/index.vue", component: "@/pages/BackstageManage/organization/index.vue",
meta: { meta: {
title: "推送管理", title: "推送管理",
icon: "", icon: "",
@@ -216,7 +216,7 @@ export const mockMenuData: MockMenuRoute[] = [
{ {
name: "job", name: "job",
path: "job", path: "job",
component: "@/pages/StageManage/organization/index.vue", component: "@/pages/BackstageManage/organization/index.vue",
meta: { meta: {
title: "主场岗位", title: "主场岗位",
icon: "", icon: "",
@@ -240,7 +240,7 @@ export const mockMenuData: MockMenuRoute[] = [
{ {
path: "flow", path: "flow",
name: "flow", name: "flow",
component: "@/pages/StageManage/organization/index.vue", component: "@/pages/BackstageManage/organization/index.vue",
meta: { meta: {
title: "流程管理", title: "流程管理",
icon: "", icon: "",
@@ -250,7 +250,7 @@ export const mockMenuData: MockMenuRoute[] = [
}, },
{ {
name: "organization", name: "organization",
component: "@/pages/StageManage/organization/index.vue", component: "@/pages/BackstageManage/organization/index.vue",
path: "organization", path: "organization",
meta: { meta: {
title: "组织管理", title: "组织管理",
@@ -262,7 +262,7 @@ export const mockMenuData: MockMenuRoute[] = [
{ {
name: "personnel", name: "personnel",
path: "personnel", path: "personnel",
component: "@/pages/StageManage/organization/index.vue", component: "@/pages/BackstageManage/organization/index.vue",
meta: { meta: {
title: "人员管理", title: "人员管理",
icon: "", icon: "",
@@ -273,7 +273,7 @@ export const mockMenuData: MockMenuRoute[] = [
{ {
name: "permission", name: "permission",
path: "permission", path: "permission",
component: "@/pages/StageManage/organization/index.vue", component: "@/pages/BackstageManage/organization/index.vue",
meta: { meta: {
title: "权限管理", title: "权限管理",
icon: "", icon: "",
@@ -284,7 +284,7 @@ export const mockMenuData: MockMenuRoute[] = [
{ {
name: "dict", name: "dict",
path: "dict", path: "dict",
component: "@/pages/StageManage/organization/index.vue", component: "@/pages/BackstageManage/dict/index.vue",
meta: { meta: {
title: "字典管理", title: "字典管理",
icon: "", icon: "",

View File

@@ -4,7 +4,9 @@
<el-aside :width="width"> <el-aside :width="width">
<div class="mj-aside-content"> <div class="mj-aside-content">
<!-- 顶部company公司标记 --> <!-- 顶部company公司标记 -->
<div class="mj-aside-company"></div> <div class="mj-aside-logo">
<img :src="companyLogo" class="mj-company-logo" />
</div>
<div class="mj-aside-menu"> <div class="mj-aside-menu">
<div class="mj-aside-title" v-show="!isCollapse"> <div class="mj-aside-title" v-show="!isCollapse">
{{ topTitle }} {{ topTitle }}
@@ -25,12 +27,15 @@
</el-aside> </el-aside>
<el-container> <el-container>
<el-header class="mj-header-content"> <el-header class="mj-header-content">
<!-- 左侧的菜单展示 -->
<mjMenus <mjMenus
:menuList="topLevelMenuList" :menuList="topLevelMenuList"
mode="horizontal" mode="horizontal"
:active-menu="selectedTopMenu" :active-menu="selectedTopMenu"
@menu-select="handleTopMenuSelect" @menu-select="handleTopMenuSelect"
/> />
<!-- 右侧用户的内容 -->
<rightMenuGroup />
</el-header> </el-header>
<el-main> <el-main>
<!-- <card-item :list="[1,2,3,4,5,6]"/> --> <!-- <card-item :list="[1,2,3,4,5,6]"/> -->
@@ -44,6 +49,8 @@
import mjMenus from "@/components/standardMenu/index.vue"; import mjMenus from "@/components/standardMenu/index.vue";
import { useUserStore } from "@/store"; import { useUserStore } from "@/store";
import { DArrowLeft, DArrowRight } from "@element-plus/icons-vue"; import { DArrowLeft, DArrowRight } from "@element-plus/icons-vue";
import rightMenuGroup from './rightMenuGroup.vue';
import companyLogo from '@/assets/images/logo.png';
defineOptions({ defineOptions({
name: "Layout", name: "Layout",
}); });
@@ -212,10 +219,18 @@ onUnmounted(() => {
flex-direction: column; flex-direction: column;
border-right: 1px solid var(--mj-border-color); border-right: 1px solid var(--mj-border-color);
.mj-aside-company { .mj-aside-logo {
height: var(--mj-menu-header-height); height: var(--mj-menu-header-height);
line-height: var(--mj-menu-header-height);
border-bottom: 1px solid var(--mj-border-color); border-bottom: 1px solid var(--mj-border-color);
flex-shrink: 0; flex-shrink: 0;
.mj-company-logo{
width: 39px;
height: 32px;
display: inline-block;
vertical-align: middle;
margin-left: 20px;
}
} }
.mj-aside-menu { .mj-aside-menu {
@@ -245,6 +260,14 @@ onUnmounted(() => {
} }
} }
.mj-header-content{
display: flex;
justify-content: space-between;
.mj-standard-menu{
flex: 1;
}
}
.mj-header-content { .mj-header-content {
--el-header-padding: 0; --el-header-padding: 0;
border-bottom: 1px solid var(--mj-border-color); border-bottom: 1px solid var(--mj-border-color);

View File

@@ -0,0 +1,197 @@
<template>
<div class="mj-right-menu-group">
<div class="user-header-container">
<div class="action-group">
<div class="action-item">
<el-icon :size="16"><Monitor /></el-icon>
</div>
<div class="action-item">
<el-badge is-dot class="notice-badge">
<el-icon :size="16"><Bell /></el-icon>
</el-badge>
</div>
</div>
<el-dropdown placement="bottom" trigger="click" @command="handleCommand">
<div class="user-info">
<div class="text-meta">
<span class="userinfo-username">{{ userInfo.username }}</span>
<span class="userinfo-role">SUPER ADMIN</span>
</div>
<el-avatar :size="30" :src="userInfo.avatar" />
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">
<div class="logout-section-content">
<svg
class="logout-section-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<span class="logout-section-text">退出登录</span>
</div>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
<script setup lang="ts">
import { Monitor, Bell } from "@element-plus/icons-vue";
import TokenManager from "@/utils/storage";
import { useUserStore } from "@/store";
defineOptions({ name: "RightMenuGroup" });
const userStore = useUserStore();
const tokenManager = TokenManager.getInstance();
const router = useRouter();
const handleCommand = (command: string) => {
if (command === "logout") {
// 退出登录
tokenManager.clearStorage();
router.replace({ name: "Login" });
}
};
// 获取当前的用户的数据信息
const userInfo = computed(() => {
return userStore.userInfo;
});
</script>
<style lang="scss" scoped>
.mj-right-menu-group {
$text-main: #303133;
$text-secondary: #909399;
$border-color: #e4e7ed;
$bg-capsule: #f5f7fa;
.user-header-container {
display: flex;
align-items: center;
height: 100%;
gap: 24px;
font-family: "PingFang SC", "Helvetica Neue", Arial, sans-serif;
// 左侧胶囊形状容器
.action-group {
height: 36px;
display: flex;
align-items: center;
background-color: $bg-capsule;
border: 1px solid $border-color;
border-radius: 30px;
padding: 6px 16px;
box-sizing: border-box;
.action-item {
display: flex;
align-items: center;
cursor: pointer;
color: $text-secondary;
transition: color 0.3s;
&:not(:last-child) {
&::after {
content: "";
width: 1px;
height: 20px;
background-color: #d3dce9;
margin: 0 16px;
}
}
&:hover {
color: var(--el-color-primary);
}
.notice-badge {
display: flex;
align-items: center;
// 调整 ElementPlus badge 小红点位置
:deep(.el-badge__content.is-fixed.is-dot) {
right: 2px;
top: 2px;
background-color: #ff5722;
}
}
}
}
// 用户信息区域
.user-info {
display: flex;
align-items: center;
gap: 12px;
padding: 2px 4px 2px 16px;
border: 1px solid transparent;
border-radius: 30px;
box-sizing: border-box;
transition: border-color 0.3s ease, background-color 0.3s ease-in-out;
.el-avatar {
transition: transform 0.3s ease;
}
&:hover {
background-color: #f5f7fa;
border-color: $border-color;
.el-avatar {
transform: scale(1.06);
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
}
}
.text-meta {
display: flex;
flex-direction: column;
align-items: flex-end; // 文字向右对齐
line-height: 1.2;
.userinfo-username {
font-size: 14px;
font-weight: 600;
margin-bottom: 2px;
color: $text-main;
}
.userinfo-role {
font-size: 12px;
color: #9b9ea3;
letter-spacing: 0.5px;
}
}
.el-avatar {
border: 2px solid #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
cursor: pointer;
}
}
}
}
.logout-section-content {
width: 120px;
display: flex;
align-items: center;
cursor: pointer;
transition: background-color 0.2s;
.logout-section-icon {
width: 18px;
height: 18px;
color: #8c8c8c;
margin-right: 12px;
}
.logout-section-text {
font-size: 14px;
color: #ff4d4f;
font-weight: 400;
}
}
</style>

View File

@@ -52,9 +52,6 @@
<template #label> <template #label>
<div class="password-label"> <div class="password-label">
<span>密码</span> <span>密码</span>
<el-link type="primary" underline="never" class="forgot-pass"
>忘记密码</el-link
>
</div> </div>
</template> </template>
<el-input <el-input
@@ -72,7 +69,7 @@
</el-checkbox> </el-checkbox>
</div> </div>
<el-button type="primary" class="login-btn" @click="handleLogin"> <el-button type="primary" class="login-btn" @click="handleLogin" :loading="loading">
登录系统 <el-icon class="el-icon--right"><Right /></el-icon> 登录系统 <el-icon class="el-icon--right"><Right /></el-icon>
</el-button> </el-button>
</el-form> </el-form>
@@ -90,42 +87,32 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref } from "vue";
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 { 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 TokenManager from '@/utils/storage';
defineOptions({ name: "Login" }); defineOptions({ name: "Login" });
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const userStore = useUserStore(); const userStore = useUserStore();
const { setTokens } = useTokenRefresh(import.meta.env.VITE_APP_BASE_API || ""); const tokenManager = TokenManager.getInstance();
const loginFormRef = ref<FormInstance>(); const loginFormRef = ref<FormInstance>();
const loading = ref(false); const loading = ref(false);
const loginForm = reactive({ const loginForm = reactive({
username: "admin", username: "user",
password: "123456", password: "password"
}); });
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",
},
], ],
password: [ password: [
{ required: true, message: "请输入密码", trigger: "blur" }, { required: true, message: "请输入密码", trigger: "blur" }
{ min: 6, max: 20, message: "密码长度在 6 到 20 个字符", trigger: "blur" },
], ],
}; };
@@ -140,21 +127,24 @@ const handleLogin = async () => {
const response = await login({ const response = await login({
username: loginForm.username, username: loginForm.username,
password: loginForm.password, password: loginForm.password,
grant_type: 'password'
}); });
if (response) {
if (response.code === 0 && response.data) { const { access_token, refresh_token, expires_in,username,userId,avatar } = response;
const { accessToken, refreshToken, expiresIn, userInfo } = const userInfo = {
response.data; username,
userId,
avatar
}
// 保存 token // 保存 token
setTokens({ tokenManager.setToken('accessToken',access_token);
accessToken, tokenManager.setToken('refreshToken',refresh_token);
refreshToken, tokenManager.setToken('expiresAt',expires_in);
expiresAt: Date.now() + (expiresIn || 7200) * 1000, tokenManager.setToken('userInfo',JSON.stringify(userInfo));
});
// 保存用户信息 // 保存用户信息
userStore.setToken(accessToken); userStore.setToken(refresh_token);
// TODO:获取用户信息
userStore.setUserInfo(userInfo); userStore.setUserInfo(userInfo);
ElMessage.success("登录成功"); ElMessage.success("登录成功");
@@ -162,8 +152,6 @@ const handleLogin = async () => {
// 跳转到首页或之前访问的页面 // 跳转到首页或之前访问的页面
const redirect = (route.query.redirect as string) || "/"; const redirect = (route.query.redirect as string) || "/";
router.push(redirect); router.push(redirect);
} else {
ElMessage.error(response.msg || "登录失败");
} }
} catch (error: any) { } catch (error: any) {
console.error("Login error:", error); console.error("Login error:", error);

View File

@@ -0,0 +1,121 @@
<template>
<div class="dict-manage">
<el-dialog
class="standard-ui-dialog"
:title
:width="558"
:model-value="dialogVisible"
@close="onCancel"
destroy-on-close
:close-on-click-modal="false"
:close-on-press-escape="false"
>
<el-form
ref="ruleFormRef"
:model="form"
:rules="rules"
label-width="120px"
>
<el-form-item label="字典名称:" prop="name">
<el-input placeholder="请输入字典名称" v-model="form.name"></el-input>
</el-form-item>
<el-form-item prop="type">
<template #label>
<div class="el-form-item-owerner">
<span>字典类型</span>
<el-tooltip content="字典类型" placement="top-start">
<el-icon><QuestionFilled /></el-icon>
</el-tooltip>
<span></span>
</div>
</template>
<el-input placeholder="请输入字典类型" v-model="form.type"></el-input>
</el-form-item>
<el-form-item label="管理人员:">
<el-input
placeholder="请选择管理人员"
v-model="form.manager"
></el-input>
</el-form-item>
<el-form-item label="状态:" prop="resource">
<el-radio-group v-model="form.resource">
<el-radio :value="1">显示</el-radio>
<el-radio :value="0">隐藏</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="备注:">
<el-input
type="textarea"
:row="4"
placeholder="请输入备注"
v-model="form.remark"
></el-input>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="onCancel">取消</el-button>
<el-button @click="onConfirm(ruleFormRef)" type="primary">确认</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from "vue";
import type { FormInstance, FormRules } from "element-plus";
import { QuestionFilled } from "@element-plus/icons-vue";
defineOptions({ name: "DictManage" });
const { dialogVisible = false,row={},title="字典管理" } = defineProps<{
dialogVisible: boolean;
row: any;
title?:string
}>();
const emit = defineEmits<{
(e: "update:dialogVisible", value: boolean): void;
(e: "close"): void;
(e: "confirm"): void;
}>();
const ruleFormRef = ref<FormInstance>();
const form = reactive({
name: "",
type: "",
manager: "",
status: "",
remark: "",
});
const rules = reactive({
name: [{ required: true, message: "请输入字典名称", trigger: "blur" }],
type: [{ required: true, message: "请输入字典类型", trigger: "blur" }],
resource: [{ required: true, message: "请选择状态", trigger: "change" }],
});
// 确定
const onConfirm = async (formEl: FormInstance | undefined) => {
if (!formEl) return;
await formEl.validate((valid, fields) => {
if (valid) {
emit("update:dialogVisible", false);
emit("confirm",form);
} else {
console.log("error submit!", fields);
}
});
};
// 取消
const onCancel = () => {
ruleFormRef.value && ruleFormRef.value.resetFields()
emit("update:dialogVisible", false);
};
</script>
<style lang="scss" scoped>
.el-form-item-owerner{
display: inline-flex;
align-items: center;
gap: 3px;
}
</style>

View File

@@ -0,0 +1,63 @@
<template>
<div class="mj-dict">
<stageBreadcrumbs title="组织管理">
<template #content>
<el-button :icon="Plus" type="primary" @click="addDict">新增字典</el-button>
</template>
<template #action>
<div class="mj-dict-actions">
<CommonFilter ref="filterPopover"></CommonFilter>
<div class="search-dict-input">
<el-input
placeholder="搜索字典..."
class="auto-expand-input"
></el-input>
</div>
</div>
</template>
</stageBreadcrumbs>
<!-- Table内容 -->
<!-- 新增-编辑字典弹窗 -->
<dict-manage v-model:dialogVisible="visible" />
</div>
</template>
<script setup lang="ts">
import { Plus } from "@element-plus/icons-vue";
import dictManage from "./DictManage.vue";
defineOptions({ name: "Dictionary" });
const visible = ref(false);
const addDict = () => {
visible.value = true;
};
</script>
<style lang="scss" scoped>
.mj-dict {
.stage-breadcrumbs {
border-bottom-color: transparent;
}
.mj-dict-actions {
display: flex;
align-items: center;
gap: 14px;
}
.search-dict-input {
width: 160px;
transition: width 0.3s ease;
&:focus-within {
width: 224px;
}
.auto-expand-input {
width: 100%;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
}
}
</style>

View File

@@ -0,0 +1,13 @@
<template>
<div class="">
</div>
</template>
<script setup lang="ts">
import {reactive,ref,onMounted} from "vue"
defineOptions({})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,169 @@
<template>
<div class="audit-log-container">
<div class="section-header">
<el-icon class="history-icon"><Timer /></el-icon>
<span class="title">变更日志</span>
</div>
<el-timeline>
<el-timeline-item
v-for="(item, index) in logData"
:key="index"
:hide-timestamp="true"
>
<template #dot>
<div class="custom-node-dot"></div>
</template>
<div class="log-card-wrapper">
<div class="log-header">
<div class="log-action">{{ item.action }}</div>
<div class="log-time">{{ item.time }}</div>
</div>
<div class="log-content">
{{ item.content }}
</div>
<div class="log-operator">
操作人<span class="name">{{ item.operator }}</span>
</div>
</div>
</el-timeline-item>
</el-timeline>
</div>
</template>
<script setup>
import { Timer } from "@element-plus/icons-vue";
const logData = [
{
action: "新增部门",
time: "2025-12-29 16:06:11",
content: "在集团1下新增东京分公司",
operator: "名匠",
},
{
action: "修改配置",
time: "2025-12-29 14:20:05",
content: "更新了企业 CorpID",
operator: "张三",
},
{
action: "变更负责人",
time: "2025-12-29 10:15:30",
content: "广州分公司行政负责人变更为张荣平",
operator: "李四",
},
];
</script>
<style lang="scss" scoped>
// 变量定义
$bg-default: #fcfdfe;
$bg-hover: #ffffff;
$primary-color: #409eff;
$border-color: #ebeef5;
.audit-log-container {
margin: 0 2px;
.section-header {
display: flex;
align-items: center;
margin-bottom: 24px;
.history-icon {
font-size: 18px;
margin-right: 8px;
color: #303133;
}
.title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
}
// 时间轴整体样式
:deep(.el-timeline) {
padding-left: 2px;
.el-timeline-item__tail {
border-left: 2px solid #e4e7ed;
left: 5px; // 配合圆点居中
}
.el-timeline-item__wrapper {
padding-left: 32px;
top: -4px; // 让内容顶部与圆点对齐
}
}
// 自定义圆环节点
.custom-node-dot {
width: 12px;
height: 12px;
background-color: #fff;
border: 2px solid #409eff; // 变细边框
border-radius: 50%;
position: relative;
left: -1px;
z-index: 2;
box-sizing: border-box;
}
// 右侧内容块样式
.log-card-wrapper {
background-color: $bg-default;
border: 1px solid $border-color;
border-radius: 8px;
padding: 16px 24px;
transition: all 0.3s ease; // 平滑过渡
cursor: default;
margin-bottom: 4px;
&:hover {
background-color: $bg-hover;
border-color: transparent; // 悬浮时隐藏边框更显高级
box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.08); // 柔和的阴影
transform: translateY(-2px); // 微小的上移效果
}
.log-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.log-action {
font-weight: 600;
font-size: 15px;
color: #303133;
}
.log-time {
font-size: 13px;
color: #909399;
}
}
.log-content {
font-size: 14px;
color: #606266;
line-height: 1.6;
margin-bottom: 12px;
}
.log-operator {
font-size: 13px;
color: #909399;
.name {
color: #303133;
font-weight: 500;
margin-left: 4px;
}
}
}
}
</style>

View File

@@ -0,0 +1,224 @@
<template>
<div class="organization-detail-container">
<el-card class="header-card" shadow="never">
<div class="header-content">
<div class="left-section">
<div class="icon-wrapper">
<el-icon :size="32" color="#409eff"><OfficeBuilding /></el-icon>
</div>
<div class="info-text">
<div class="title-row">
<span class="main-title">集团1</span>
<el-tag size="small" effect="plain" class="title-tag"
>集团</el-tag
>
</div>
<div class="sub-id">ID: 3</div>
</div>
</div>
<div class="right-actions">
<el-button plain :icon="EditPen">编辑配置</el-button>
<el-button type="danger" plain :icon="CircleClose">禁用</el-button>
</div>
</div>
</el-card>
<div class="content-grid">
<el-row :gutter="20">
<el-col :span="12">
<el-card class="info-card" shadow="never">
<template #header>
<div class="mj-panel-title">基本信息</div>
</template>
<div class="info-list">
<div
class="info-item"
v-for="(base, baseIndex) in baseList"
:key="baseIndex"
>
<div class="info-label">{{ base.name }}</div>
<div class="info-value">
<el-button link type="primary" v-if="base.slotName === 'link'">{{base.value}} &gt;</el-button>
<span v-else>{{ base.value }}</span>
</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card class="info-card" shadow="never">
<template #header>
<div class="mj-panel-title">系统属性</div>
</template>
<!-- 系统属性列表 -->
<div class="info-list">
<div
class="info-item"
v-for="(item, index) in systemList"
:key="index"
>
<div class="info-label">{{ item.name }}</div>
<div class="info-value">
<!-- 普通展示内容 -->
<span v-if="!item.slotName">{{ item.value }}</span>
<!-- slot 方式处理系统属性中的头像 -->
<div class="_user-info" v-if="item.slotName === 'avatar'">
<el-avatar :size="20" class="mini-avatar"></el-avatar>
<span>{{ item.value }}</span>
</div>
</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</div>
</template>
<script setup>
import { OfficeBuilding, EditPen, CircleClose } from "@element-plus/icons-vue";
const systemList = [
{
name: "生效时间",
value: "2024-01-01",
},
{
name: "过期时间",
value: "永久生效",
},
{
name: "最后更新",
value: "2025-12-29 16:06",
},
{
name: "更新人",
value: "名匠",
slotName: "avatar",
},
];
const baseList = [
{
name: "部门/公司名称",
value: "广州分公司",
},
{
name: "上级单位",
value: "集团1",
},
{
name: "同步企微",
value: "已开启",
slotName: "link",
},
{
name: "部门负责人",
value: "赵康, 李思奇, 董峥",
},
];
</script>
<style lang="scss" scoped>
.organization-detail-container {
margin: 0 2px;
.header-card {
border-radius: 12px;
margin-bottom: 20px;
border: 1px solid #ebeef5;
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
.left-section {
display: flex;
align-items: center;
.icon-wrapper {
width: 56px;
height: 56px;
border: 1px solid #e2e8f0;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
box-shadow: 0 0 4px #e2e8f0;
}
.title-row {
display: flex;
align-items: center;
gap: 8px;
.main-title {
font-size: 20px;
font-weight: 600;
color: #303133;
}
}
.sub-id {
font-size: 13px;
color: #909399;
margin-top: 4px;
}
}
}
}
.content-grid {
margin-bottom: 2px;
.info-card {
border-radius: 12px;
height: 100%;
// 移除 card 默认 padding 以便底线撑满
:deep(.el-card__body) {
padding: 0 20px;
}
.info-list {
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
border-bottom: 1px solid #f8fafc; // 原型要求的色值
&:last-child {
border-bottom: none; // 最后一行去掉底线
}
.info-label {
font-size: 14px;
color: #86909c; // 较淡的标签文字
}
.info-value {
font-size: 14px;
color: #1d2129; // 较深的数值文字
text-align: right;
}
// 处理系统属性中的头像
._user-info {
display: flex;
align-items: center;
gap: 8px;
.mini-avatar {
background-color: #3c7eff;
font-size: 10px;
}
}
}
}
}
}
}
</style>

View File

@@ -3,7 +3,9 @@
<!-- 顶部的tabs菜单 --> <!-- 顶部的tabs菜单 -->
<div class="organization-tabs"> <div class="organization-tabs">
<stageBreadcrumbs title="组织管理"> <stageBreadcrumbs title="组织管理">
<template #content> 内容占位 </template> <template #content>
<OverflowTabs v-model="activeTab" :items="tabList" :height="60"/>
</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>
@@ -39,26 +41,41 @@
</div> </div>
</div> </div>
<div class="mj-organization-card organization-info"> <div class="mj-organization-card organization-info">
<div class="mj-panel_header"> <el-tabs v-model="activeName" class="organization-info-tabs">
<el-tabs v-model="activeName"> <el-tab-pane label="基础信息" name="baseInfo">
<el-tab-pane label="基础信息" name="first"></el-tab-pane> <OrganizationDetail />
<el-tab-pane label="动态日志" name="second"></el-tab-pane> </el-tab-pane>
<el-tab-pane label="动态日志" name="auditLogs">
<AuditLogs />
</el-tab-pane>
</el-tabs> </el-tabs>
</div> </div>
</div> </div>
</div> </div>
</div>
</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";
import OverflowTabs from "@/components/overflowTabs/index.vue";
import AuditLogs from "./AuditLogs.vue";
import OrganizationDetail from "./OrganizationDetail.vue";
import { OfficeBuilding, EditPen, CircleClose } from "@element-plus/icons-vue";
defineOptions({ name: "Organization" }); defineOptions({ name: "Organization" });
const addValue = ref(""); const addValue = ref("");
const search = ref(""); const search = ref("");
const activeName = ref("baseInfo");
const activeName = ref("first"); // Tabs
const activeTab = ref(1);
const tabList = ref([
{ id: 1, label: '集团1' },
{ id: 2, label: '集团2' },
{ id: 3, label: '集团3' },
{ id: 4, label: '集团4' },
{ id: 5, label: '集团5' },
{ id: 6, label: '集团6' },
]);
interface Tree { interface Tree {
label: string; label: string;
children?: Tree[]; children?: Tree[];
@@ -134,6 +151,12 @@ const defaultProps = {
<style lang="scss" scoped> <style lang="scss" scoped>
@use "sass:math"; @use "sass:math";
.mj-organization { .mj-organization {
.organization-tabs{
:deep(.stage-breadcrumbs){
// border-bottom: none;
padding:0;
}
}
.mj-organization-card { .mj-organization-card {
border-radius: 16px; border-radius: 16px;
border: 1px solid #e2e8f099; border: 1px solid #e2e8f099;
@@ -141,13 +164,8 @@ const defaultProps = {
margin-top: $mj-padding-standard; margin-top: $mj-padding-standard;
box-shadow: 0 0 6px #e9e8e8; box-shadow: 0 0 6px #e9e8e8;
} }
.organization-tabs {
}
.organization-content { .organization-content {
display: flex; display: flex;
height: 400px;
gap: 16px; gap: 16px;
.org-tree { .org-tree {
flex: 0 0 280px; flex: 0 0 280px;
@@ -173,6 +191,11 @@ const defaultProps = {
} }
.organization-info { .organization-info {
flex: 1; flex: 1;
padding: 0 22px 22px 22px;
.organization-info-tabs {
--el-tabs-header-height: 54px;
--el-border-color-light: #f1f5f9;
}
} }
} }
} }

View File

@@ -0,0 +1,13 @@
<template>
<div class="">
</div>
</template>
<script setup lang="ts">
import {reactive,ref,onMounted} from "vue"
defineOptions({})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,13 @@
<template>
<div class="">
</div>
</template>
<script setup lang="ts">
import {reactive,ref,onMounted} from "vue"
defineOptions({})
</script>
<style lang="scss" scoped>
</style>

View File

@@ -1,15 +1,20 @@
import axios from "axios"; import axios from "axios";
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
import { ElNotification } from 'element-plus'; import { ElNotification } from "element-plus";
import { getMockData, shouldUseMock } from '@/mock' //mock数据信息 import { VITE_APP_BASE_API } from "../../config.js";
import TokenManager from "@/utils/storage";
import { getMockData, shouldUseMock } from "@/mock"; //mock数据信息
const baseUrl = import.meta.env.VITE_APP_BASE_API; const tokenManager = TokenManager.getInstance();
const baseUrl = "/api"; //TODO: 本地调试需要修改为/api
// 1. 锁和队列定义在类外部,确保全局唯一 // 1. 锁和队列定义在类外部,确保全局唯一
let isRefreshing = false; let isRefreshing = false;
let requestsQueue: Array<(token: string) => void> = []; let requestsQueue: Array<(token: string) => void> = [];
// 登录接口 传递参数不一样
const AUTH_OAUTH2_TOKEN_URL = "/auth/oauth2/token";
const CLIENT_CREDENTIALS = "Basic " + window.btoa("mversion:secret");
class HttpRequest { class HttpRequest {
private instance: AxiosInstance; private instance: AxiosInstance;
@@ -24,16 +29,24 @@ class HttpRequest {
} }
// --- Token 管理方法 --- // --- Token 管理方法 ---
private getAccessToken() { return localStorage.getItem("accessToken"); } private getAccessToken() {
private getRefreshToken() { return localStorage.getItem("refreshToken"); } return tokenManager.getToken("accessToken");
}
private getRefreshToken() {
return tokenManager.getToken("refreshToken");
}
private clearTokens() { private clearTokens() {
localStorage.removeItem("accessToken"); tokenManager.clearStorage();
localStorage.removeItem("refreshToken");
// 这里可以触发跳转登录逻辑例如router.push('/login') // 这里可以触发跳转登录逻辑例如router.push('/login')
} }
private setTokens(data: any) { private setTokens(data: any) {
localStorage.setItem("accessToken", data.accessToken); tokenManager.setToken("accessToken", data.accessToken);
localStorage.setItem("refreshToken", data.refreshToken); tokenManager.setToken("refreshToken", data.refreshToken);
}
// 判断是否为认证接口
private isAuthEndpoint(url: string): boolean {
return url === AUTH_OAUTH2_TOKEN_URL;
} }
private setupInterceptors() { private setupInterceptors() {
@@ -41,7 +54,11 @@ class HttpRequest {
this.instance.interceptors.request.use( this.instance.interceptors.request.use(
(config) => { (config) => {
const token = this.getAccessToken(); const token = this.getAccessToken();
if (token && config.headers) {
// 如果login接口传递clientId参数
if (this.isAuthEndpoint(config.url || "")) {
config.headers.Authorization = CLIENT_CREDENTIALS;
} else if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
} }
return config; return config;
@@ -54,12 +71,16 @@ class HttpRequest {
async (response: AxiosResponse) => { async (response: AxiosResponse) => {
const { data: res, config: originalRequest } = response; const { data: res, config: originalRequest } = response;
console.log("响应拦截器",res,originalRequest)
// TODO:如果是登录接口就不要走全局的拦截 而是直接返回当前的数据信息
if (this.isAuthEndpoint(originalRequest.url || "")) {
return res;
}
// 业务成功直接返回数据 // 业务成功直接返回数据
if (res.code === 0) return res; if (res.code === 200) return res.data;
// 重点:401 未授权处理 // 401 未授权处理
if (res.code === 401) { if (res.code === 401) {
// 如果已经在刷新中了,将请求挂起并加入队列 // 如果已经在刷新中了,将请求挂起并加入队列
if (isRefreshing) { if (isRefreshing) {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -80,23 +101,25 @@ class HttpRequest {
} }
try { try {
// 使用一个干净的 axios 实例去发刷新请求,避免拦截器死循环 // TODO:使用一个干净的 axios 实例去发刷新请求,避免拦截器死循环
const refreshRes = await axios.post(`${this.instance.defaults.baseURL}/auth/refresh`, { const refreshRes = await axios.post(
refreshToken: rToken `${this.instance.defaults.baseURL}/auth/refresh`,
}); {
refreshToken: rToken,
}
);
const newToken = refreshRes.data.data.accessToken; const newToken = refreshRes.data.data.accessToken;
this.setTokens(refreshRes.data.data); this.setTokens(refreshRes.data.data);
// 刷新成功:释放队列 // 刷新成功:释放队列
requestsQueue.forEach(callback => callback(newToken)); requestsQueue.forEach((callback) => callback(newToken));
requestsQueue = []; requestsQueue = [];
isRefreshing = false; isRefreshing = false;
// 重试本次请求 // 重试本次请求
originalRequest.headers!.Authorization = `Bearer ${newToken}`; originalRequest.headers!.Authorization = `Bearer ${newToken}`;
return this.instance(originalRequest); return this.instance(originalRequest);
} catch (error) { } catch (error) {
// 刷新失败:清理并彻底报错 // 刷新失败:清理并彻底报错
isRefreshing = false; isRefreshing = false;
@@ -107,7 +130,7 @@ class HttpRequest {
} }
// 其它业务错误 // 其它业务错误
ElNotification.error({ title: '提示', message: res.msg || '服务异常' }); ElNotification.error({ title: "提示", message: res.msg || "服务异常" });
return Promise.reject(res); return Promise.reject(res);
}, },
(error) => { (error) => {
@@ -121,63 +144,75 @@ class HttpRequest {
} }
public request<T = any>(config: AxiosRequestConfig): Promise<ApiResponse<T>> { public request<T = any>(config: AxiosRequestConfig): Promise<ApiResponse<T>> {
// 检查是否应该使用 Mock 数据 // TODO:检查是否应该使用 Mock 数据
const requestUrl = config.url || ''; // const requestUrl = config.url || '';
// if (shouldUseMock(requestUrl)) {
// 优先使用请求 URL通常是相对路径如 /api/menus // const mockData = getMockData(requestUrl, config.params, config.data);
// 这样可以直接匹配 mockApiMap 中的 key // if (mockData) {
if (shouldUseMock(requestUrl)) { // console.log(`[Mock] 使用 Mock 数据: ${requestUrl}`, mockData);
const mockData = getMockData(requestUrl, config.params, config.data); // // 模拟网络延迟
if (mockData) { // return new Promise((resolve) => {
console.log(`[Mock] 使用 Mock 数据: ${requestUrl}`, mockData); // setTimeout(() => {
// 模拟网络延迟 // resolve(mockData as ApiResponse<T>);
return new Promise((resolve) => { // }, 100);
setTimeout(() => { // });
resolve(mockData as ApiResponse<T>); // }
}, 100); // }
});
}
}
return this.instance(config) as unknown 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>> { public get<T = any>(
url: string,
params?: any,
config?: AxiosRequestConfig
): Promise<ApiResponse<T>> {
return this.request<T>({ return this.request<T>({
url, url,
method: 'get', method: "get",
params, params,
...config ...config,
}); });
} }
// POST 请求 // POST 请求
public post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<ApiResponse<T>> { public post<T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig
): Promise<ApiResponse<T>> {
return this.request<T>({ return this.request<T>({
url, url,
method: 'post', method: "post",
data, data,
...config ...config,
}); });
} }
// PUT 请求 // PUT 请求
public put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<ApiResponse<T>> { public put<T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig
): Promise<ApiResponse<T>> {
return this.request<T>({ return this.request<T>({
url, url,
method: 'put', method: "put",
data, data,
...config ...config,
}); });
} }
// DELETE 请求 // DELETE 请求
public delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> { public delete<T = any>(
url: string,
config?: AxiosRequestConfig
): Promise<ApiResponse<T>> {
return this.request<T>({ return this.request<T>({
url, url,
method: 'delete', method: "delete",
...config ...config,
}); });
} }
} }

View File

@@ -1,13 +1,12 @@
import { createWebHistory, createRouter, type RouteRecordRaw } from 'vue-router' import { createWebHistory, createRouter, type RouteRecordRaw } from 'vue-router'
import { useUserStore } from '@/store' import { useUserStore } from '@/store'
import { getRouteMenus } from '@/api' import { getRouteMenus } from '@/api'
import useTokenRefresh from '@/hooks/useTokenRefresh' import TokenManager from '@/utils/storage';
import Login from '@/pages/Login/index.vue'; import Login from '@/pages/Login/index.vue';
import HomeView from '@/pages/Layout/index.vue'; import HomeView from '@/pages/Layout/index.vue';
const baseUrl = import.meta.env.VITE_APP_BASE_API || ''; const tokenManager = TokenManager.getInstance();
// 基础路由(不需要权限验证) // 基础路由(不需要权限验证)
const constantRoutes: RouteRecordRaw[] = [ const constantRoutes: RouteRecordRaw[] = [
{ {
@@ -27,7 +26,7 @@ const asyncRoutes: RouteRecordRaw[] = [
path: '/', path: '/',
name: 'Layout', name: 'Layout',
component: HomeView, component: HomeView,
redirect: '/home', // redirect: '/home',
meta: { meta: {
requiresAuth: true, requiresAuth: true,
}, },
@@ -57,7 +56,7 @@ const loadComponent = (componentPath: string) => {
fullPath = componentPath fullPath = componentPath
} else if (componentPath.includes('/')) { } else if (componentPath.includes('/')) {
// 补全路径,确保以 @/pages 开头 // 补全路径,确保以 @/pages 开头
fullPath = componentPath.startsWith('pages/') ? `@/${componentPath}` : `@/pages/${componentPath}` fullPath = componentPath.startsWith('pages/') ? `@/${componentPath}` : `@/pages/${componentPath}/index.vue`
} else { } else {
// 补全 index.vue // 补全 index.vue
fullPath = `@/pages/${componentPath}/index.vue` fullPath = `@/pages/${componentPath}/index.vue`
@@ -76,34 +75,35 @@ const loadComponent = (componentPath: string) => {
} }
// 将后端返回的路由数据转换为 Vue Router 路由 // 将后端返回的路由数据转换为 Vue Router 路由
const transformRoutes = (routes: any[]): RouteRecordRaw[] => { const transformRoutes = (routes: any[], parentCode: string = ''): RouteRecordRaw[] => {
return routes.map((route) => { return routes.flatMap((route) => {
const component = route.component ? loadComponent(route.component) : undefined const fullCode = parentCode ? `${parentCode}/${route.code}` : route.code;
// 构建基础路由对象
// 如果当前路由有子路由,说明它是一个路由前缀,不需要组件
if (route.children && route.children.length > 0) {
// 将子路由的路径加上当前路由的前缀,然后递归处理
return transformRoutes(route.children, fullCode);
} else {
// 叶子节点才需要组件和路由配置
const component = fullCode ? loadComponent(fullCode) : undefined;
const routeRecord: any = { const routeRecord: any = {
path: route.path, path: route.code,
name: route.name || route.path, name: route.code,
meta: { meta: {
title: route.meta?.title || route.title || route.name, title: route.name,
icon: route.meta?.icon || route.icon, icon: route.icon,
requiresAuth: route.meta?.requiresAuth !== false, // 默认需要权限
roles: route.meta?.roles || route.roles,
...route.meta, ...route.meta,
}, },
} }
// 如果有组件,添加组件属性
if (component) { if (component) {
routeRecord.component = component routeRecord.component = component;
} }
// 处理子路由 return routeRecord as RouteRecordRaw;
if (route.children && route.children.length > 0) {
routeRecord.children = transformRoutes(route.children)
} }
});
return routeRecord as RouteRecordRaw
})
} }
// 添加动态路由 // 添加动态路由
@@ -116,15 +116,50 @@ const addDynamicRoutes = async () => {
} }
try { try {
// 从后端获取路由菜单数据 // TODO:从后端获取路由菜单数据 (这边需要区分 后台的菜单 和用户的菜单)
const response = await getRouteMenus() let response:any;
if (response.code === 0 && response.data) { if (userStore.isBackendUser) {
const backendResponse = await getRouteMenus();
response = [{
code: 'stage',
name: '后台管理',
icon: '',
children: backendResponse,
}];
}else{
// response = await getUserMenus();
response = [];
}
if (response) {
const processRoutes = (routes: any[], prefix: string = ''): RouteRecordRaw[] => {
return routes.flatMap(route => {
const currentPath = prefix ? `${prefix}/${route.code}` : route.code;
if (route.children && route.children.length > 0) {
// 如果有子路由,递归处理并添加当前路径作为前缀
return processRoutes(route.children, currentPath);
} else {
// 叶子节点,创建路由记录
const component = loadComponent(currentPath);
return {
path: route.code,
name: route.code,
component: component || HomeView, // 使用Layout的组件
meta: {
title: route.name,
icon: route.icon,
...route.meta,
}
} as RouteRecordRaw;
}
});
};
// 转换路由数据 // 转换路由数据
const dynamicRoutes = transformRoutes(Array.isArray(response.data) ? response.data : [response.data]) // const dynamicRoutes = transformRoutes(Array.isArray(response) ? response : [response])
const dynamicRoutes = processRoutes(Array.isArray(response) ? response : [response])
// 将动态路由添加到 Layout 的 children 中 // 将动态路由添加到 Layout 的 children 中
const layoutRoute = router.getRoutes().find(route => route.name === 'Layout') const layoutRoute = router.getRoutes().find(route => route.name === 'Layout')
console.log('Layout route:', layoutRoute,dynamicRoutes)
if (layoutRoute) { if (layoutRoute) {
dynamicRoutes.forEach(route => { dynamicRoutes.forEach(route => {
router.addRoute('Layout', route) router.addRoute('Layout', route)
@@ -135,15 +170,14 @@ const addDynamicRoutes = async () => {
router.addRoute(route) router.addRoute(route)
}) })
} }
console.log('Layout route:', router.getRoutes())
// 保存路由数据到 store // 保存路由数据到 store
userStore.setRoutes(response.data) userStore.setRoutes(response)
// 标记路由已加载 // 标记路由已加载
userStore.isRoutesLoaded = true userStore.isRoutesLoaded = true
} }
} catch (error) { } catch (error) {
console.error('Failed to load routes:', error)
// 如果获取路由失败,清除用户数据并跳转到登录页 // 如果获取路由失败,清除用户数据并跳转到登录页
userStore.clearUserData() userStore.clearUserData()
router.push('/login') router.push('/login')
@@ -153,10 +187,10 @@ const addDynamicRoutes = async () => {
// 路由导航守卫 // 路由导航守卫
router.beforeEach(async (to, _from, next) => { router.beforeEach(async (to, _from, next) => {
const userStore = useUserStore() const userStore = useUserStore()
const { getAccessToken } = useTokenRefresh(baseUrl) const accessToken = tokenManager.getToken('accessToken');
// 获取 token // 获取 token
const token = getAccessToken() || userStore.token const token = accessToken || userStore.token
// 如果已登录,更新 store 中的 token // 如果已登录,更新 store 中的 token
if (token) { if (token) {

View File

@@ -1,7 +1,7 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import useTokenRefresh from "@/hooks/useTokenRefresh"; import TokenManager from '@/utils/storage';
const tokenManager = TokenManager.getInstance();
const baseUrl = import.meta.env.VITE_APP_BASE_API || "";
interface UserInfo { interface UserInfo {
name?: string; name?: string;
@@ -25,12 +25,14 @@ interface RouteMenu {
const useUserStore = defineStore("user", { const useUserStore = defineStore("user", {
state: () => { state: () => {
const { getAccessToken } = useTokenRefresh(baseUrl); const accessToken = tokenManager.getToken('accessToken');
const userInfo = tokenManager.getToken('userInfo');
return { return {
token: getAccessToken() || "", token: accessToken || "",
userInfo: {} as UserInfo, userInfo: userInfo ? JSON.parse(userInfo) : {},
routes: [] as RouteMenu[], routes: [] as RouteMenu[],
isRoutesLoaded: false, // 标记路由是否已加载 isRoutesLoaded: false, // 标记路由是否已加载
isBackendUser:true, //标记是否是后台用户
}; };
}, },
getters: { getters: {
@@ -54,8 +56,7 @@ const useUserStore = defineStore("user", {
this.userInfo = {}; this.userInfo = {};
this.routes = []; this.routes = [];
this.isRoutesLoaded = false; this.isRoutesLoaded = false;
const { clearTokens } = useTokenRefresh(baseUrl); tokenManager.clearStorage();
clearTokens();
}, },
}, },
}); });

View File

@@ -19,4 +19,19 @@ html,body{
} }
.filter-popper.el-popover.el-popper{
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
// 全局重新element相关样式
.mj-input-form{
.el-input{
--el-border-radius-base:10px;
--el-border-color:#E2E8F0;
}
}

View File

@@ -23,11 +23,19 @@
// 标注弹窗样式 // 标注弹窗样式
.standard-ui-dialog{ .standard-ui-dialog{
--el-dialog-padding-primary:0; &.el-dialog{
--el-dialog-inset-padding-primary:16px; --el-dialog-inset-padding-primary:16px;
--el-dialog-padding-primary:0;
--el-dialog-border-radius:16px;
--el-dialog-bg-header-footer:#FBFCFD;
--el-dialog-border-header-footer-color:#E5E7EB;
padding: var(--el-dialog-padding-primary);
}
.el-dialog__header{ .el-dialog__header{
border-bottom: 1px solid #E5E7EB; border-bottom: 1px solid var(--el-dialog-border-header-footer-color);
background-color:var(--el-dialog-bg-header-footer);
padding: var(--el-dialog-inset-padding-primary); padding: var(--el-dialog-inset-padding-primary);
border-radius: var(--el-dialog-border-radius) var(--el-dialog-border-radius) 0 0;
} }
.el-dialog__headerbtn{ .el-dialog__headerbtn{
height: 60px; height: 60px;
@@ -37,12 +45,8 @@
} }
.el-dialog__footer{ .el-dialog__footer{
padding: var(--el-dialog-inset-padding-primary); padding: var(--el-dialog-inset-padding-primary);
background-color: var(--el-dialog-bg-header-footer);
border-top: 1px solid var(--el-dialog-border-header-footer-color);
border-radius: 0 0 var(--el-dialog-border-radius) var(--el-dialog-border-radius);
} }
} }
// 全局重新element相关样式
.el-input{
--el-border-radius-base:10px;
--el-border-color:#E2E8F0;
}

View File

@@ -1,30 +1,35 @@
.mj-panel-title {
.mj-panel-title{
font-size: 15px; font-size: 15px;
font-weight: bold; font-weight: bold;
color:#1D293D; color: #1D293D;
display: flex;
align-items: center;
margin-bottom: 16px; margin-bottom: 16px;
&::before{
content:""; &::before {
content: "";
width: 4px; width: 4px;
height: 16px; height: 16px;
background-color: #155DFC; background-color: #155DFC;
display: inline-block; border-radius: 2px;
vertical-align: middle;
border-radius: 3px;
margin-right: 8px; margin-right: 8px;
margin-bottom: 2px;
} }
} }
.mj-panel_header{ .mj-panel_header {
height: 54px; height: 54px;
padding: 0 24px; padding: 0 24px;
box-sizing: border-box; box-sizing: border-box;
border-bottom: 1px solid #F1F5F9; border-bottom: 1px solid #F1F5F9;
.el-tabs{
--el-tabs-header-height:54px; .el-tabs {
--el-border-color-light:transparent; --el-tabs-header-height: 54px;
--el-border-color-light: transparent;
} }
} }
// 自定义组件中overflow-tabs高亮样式
.is-active-item-overflow-tabs {
color: #409eff;
font-weight: bold;
}

33
src/utils/storage.ts Normal file
View File

@@ -0,0 +1,33 @@
class TokenManager {
private static instance: TokenManager | null = null;
private storage: Storage;
private constructor(storageType: 'localStorage' | 'sessionStorage' = 'localStorage') {
this.storage = storageType === 'localStorage' ? localStorage : sessionStorage;
}
public static getInstance(storageType: 'localStorage' | 'sessionStorage' = 'localStorage'): TokenManager {
if (!TokenManager.instance) {
TokenManager.instance = new TokenManager(storageType);
}
return TokenManager.instance;
}
setToken(key: string, token: string): void {
this.storage.setItem(key, token);
}
getToken(key: string): string | null {
return this.storage.getItem(key);
}
removeToken(key: string): void {
this.storage.removeItem(key);
}
clearStorage(): void {
this.storage.clear();
}
}
export default TokenManager;

View File

@@ -44,7 +44,7 @@ export default defineConfig(({ mode }) => {
"/api": { "/api": {
target: VITE_APP_BASE_API, target: VITE_APP_BASE_API,
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ""), rewrite: (path) => path.replace(/^\/api/, "")
}, },
}, },
}, },