fix:完善相关页面
This commit is contained in:
14
components.d.ts
vendored
14
components.d.ts
vendored
@@ -11,6 +11,7 @@ export {}
|
|||||||
/* prettier-ignore */
|
/* prettier-ignore */
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
|
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']
|
||||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||||
@@ -18,14 +19,27 @@ declare module 'vue' {
|
|||||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||||
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']
|
||||||
|
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||||
|
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||||
|
ElForm: typeof import('element-plus/es')['ElForm']
|
||||||
|
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||||
ElHeader: typeof import('element-plus/es')['ElHeader']
|
ElHeader: typeof import('element-plus/es')['ElHeader']
|
||||||
|
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||||
ElInput: typeof import('element-plus/es')['ElInput']
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
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']
|
||||||
|
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']
|
||||||
|
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||||
|
ElTree: typeof import('element-plus/es')['ElTree']
|
||||||
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']
|
||||||
|
StageBreadcrumbs: typeof import('./src/components/stageBreadcrumbs/index.vue')['default']
|
||||||
|
StandardMenu: typeof import('./src/components/standardMenu/index.vue')['default']
|
||||||
|
StandMenu: typeof import('./src/components/standMenu/index.vue')['default']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,4 +3,19 @@ import request from '@/request';
|
|||||||
// 设置请求的参数信息
|
// 设置请求的参数信息
|
||||||
export const getUserList = (params?: any) => {
|
export const getUserList = (params?: any) => {
|
||||||
return request.get('/api/user/list', params);
|
return request.get('/api/user/list', params);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取路由菜单数据
|
||||||
|
export const getRouteMenus = () => {
|
||||||
|
return request.get('/api/menus');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 登录接口
|
||||||
|
export const login = (data: { username: string; password: string }) => {
|
||||||
|
return request.post('/api/auth/login', data);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取用户信息
|
||||||
|
export const getUserInfo = () => {
|
||||||
|
return request.get('/api/user/info');
|
||||||
};
|
};
|
||||||
128
src/components/cardItem/index.vue
Normal file
128
src/components/cardItem/index.vue
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="mj-card-container"
|
||||||
|
:style="containerStyle"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="mj-card-standard"
|
||||||
|
v-for="(card,carIndex) in list"
|
||||||
|
:key="carIndex"
|
||||||
|
@click="cardItemClick(card, carIndex)"
|
||||||
|
>
|
||||||
|
<slot name="cardTip">
|
||||||
|
<div class="mj-card-standard-tip" :style="standardTopStyle"></div>
|
||||||
|
</slot>
|
||||||
|
<div class="mj-card-standard-content">
|
||||||
|
<slot name="content"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineOptions({ name: 'CardItem' });
|
||||||
|
const { list = [], maxColumns = 4, standardTopStyle } = defineProps<{
|
||||||
|
list: any[]
|
||||||
|
maxColumns?: number // 每行最大展示数量,默认 4
|
||||||
|
standardTopStyle?: string | Record<string, any> // 外部传入的顶部样式
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 向外抛出的事件
|
||||||
|
const emits = defineEmits<{
|
||||||
|
/**
|
||||||
|
* 卡片点击事件
|
||||||
|
* @param e 事件名
|
||||||
|
* @param payload 点击的卡片数据和索引
|
||||||
|
*/
|
||||||
|
(e: 'card-click', payload: { item: any; index: number }): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 根据屏幕宽度和最大列数计算实际列数
|
||||||
|
const screenWidth = ref(window.innerWidth)
|
||||||
|
|
||||||
|
const containerStyle = computed(() => {
|
||||||
|
let cols = maxColumns
|
||||||
|
|
||||||
|
// 响应式调整
|
||||||
|
if (screenWidth.value <= 640) {
|
||||||
|
cols = 1
|
||||||
|
} else if (screenWidth.value <= 900) {
|
||||||
|
cols = Math.min(maxColumns, 2)
|
||||||
|
} else if (screenWidth.value <= 1200) {
|
||||||
|
cols = Math.min(maxColumns, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 卡片点击事件,向外抛出当前卡片数据和索引
|
||||||
|
const cardItemClick = (item: any, index: number) => {
|
||||||
|
emits('card-click', { item, index })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听窗口大小变化
|
||||||
|
const handleResize = () => {
|
||||||
|
screenWidth.value = window.innerWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.mj-card-container {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.mj-card-standard {
|
||||||
|
--radius: 16px;
|
||||||
|
--padding-standard: 20px;
|
||||||
|
--border-color: #e5e7eb;
|
||||||
|
--background-color: #fff;
|
||||||
|
--tw-shadow: 0 8px 12px #0000001a, 0 0px 12px #0000001a;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
min-height: 140px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
transition: box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--tw-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mj-card-standard-tip {
|
||||||
|
--height: 8px;
|
||||||
|
height: var(--height);
|
||||||
|
border-top-left-radius: inherit;
|
||||||
|
border-top-right-radius: inherit;
|
||||||
|
background-color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mj-card-standard-content {
|
||||||
|
padding: var(--padding-standard);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 平板:调整间距
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.mj-card-container {
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移动端:调整间距
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.mj-card-container {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
45
src/components/stageBreadcrumbs/index.vue
Normal file
45
src/components/stageBreadcrumbs/index.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<template>
|
||||||
|
<div class="stage-breadcrumbs">
|
||||||
|
<div class="mj-panel-title">{{ title }}</div>
|
||||||
|
<div class="stage-breadcrumbs-content">
|
||||||
|
<slot name="content"></slot>
|
||||||
|
</div>
|
||||||
|
<div class="stage-breadcrumbs-action">
|
||||||
|
<slot name="action"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineOptions({ name: "StageBreadcrumbs" });
|
||||||
|
|
||||||
|
const { title } = defineProps<{
|
||||||
|
title: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.stage-breadcrumbs{
|
||||||
|
padding: $mj-padding-standard 0;
|
||||||
|
align-items: center;
|
||||||
|
background-color: transparent;
|
||||||
|
border-bottom: 1px solid #E2E8F099;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
:deep(.mj-panel-title){
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-breadcrumbs-content{
|
||||||
|
flex: 1;
|
||||||
|
&::before{
|
||||||
|
content:'';
|
||||||
|
display: inline-block;
|
||||||
|
width: 1px;
|
||||||
|
height: 16px;
|
||||||
|
background-color: #E2E8F0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
127
src/components/standardMenu/index.vue
Normal file
127
src/components/standardMenu/index.vue
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mj-standard-menu">
|
||||||
|
<el-menu
|
||||||
|
:default-active="activeIndex"
|
||||||
|
:active-text-color="mode === 'horizontal' ? '#409EFF' : undefined"
|
||||||
|
class="mj-menu"
|
||||||
|
:mode="mode"
|
||||||
|
:collapse="isCollapse"
|
||||||
|
@select="handleMenuSelect"
|
||||||
|
router
|
||||||
|
>
|
||||||
|
<template v-for="item in menuList" :key="item.path">
|
||||||
|
<el-sub-menu v-if="item.children && item.children.length > 0">
|
||||||
|
<template #title>
|
||||||
|
<el-icon>
|
||||||
|
<location />
|
||||||
|
</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>
|
||||||
|
<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>
|
||||||
|
<template #title>{{ item.meta.title }}</template>
|
||||||
|
</el-menu-item>
|
||||||
|
</template>
|
||||||
|
</el-menu>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Location } from '@element-plus/icons-vue'
|
||||||
|
defineOptions({ name: "standardMenu" })
|
||||||
|
|
||||||
|
const {mode="vertical",menuList,isCollapse,activeMenu} = defineProps<{
|
||||||
|
mode?: 'vertical' | 'horizontal'
|
||||||
|
menuList: any[]
|
||||||
|
isCollapse?:boolean
|
||||||
|
activeMenu?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'menu-select', path: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const activeIndex = computed(() => {
|
||||||
|
// 如果传入了 activeMenu,使用它;否则使用默认值
|
||||||
|
return activeMenu || '1-1'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 处理菜单选中事件
|
||||||
|
const handleMenuSelect = (index: string) => {
|
||||||
|
// 如果是水平模式(顶部菜单),触发选中事件
|
||||||
|
if (mode === 'horizontal') {
|
||||||
|
emit('menu-select', index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.mj-standard-menu {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
:deep(.el-menu) {
|
||||||
|
--el-menu-border-color: transparent;
|
||||||
|
border-right: none;
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 菜单项和子菜单标题的基础样式
|
||||||
|
:deep(.el-menu-item),
|
||||||
|
:deep(.el-sub-menu__title) {
|
||||||
|
transition: padding 0.3s;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
// 文字容器样式 - 使用 flex 布局避免压缩
|
||||||
|
span {
|
||||||
|
display: inline-block;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: auto;
|
||||||
|
max-width: 1000px; // 设置一个足够大的值
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-width 0.25s, opacity 0.25s, width 0.25s;
|
||||||
|
opacity: 1;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收缩时隐藏文字 - 先隐藏文字,再改变宽度,避免压缩感
|
||||||
|
:deep(.el-menu--collapse) {
|
||||||
|
.el-menu-item span,
|
||||||
|
.el-sub-menu__title span {
|
||||||
|
max-width: 0;
|
||||||
|
width: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s, max-width 0.2s 0.1s, width 0.2s 0.1s;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 展开时显示文字 - 先改变宽度,再显示文字,避免压缩感
|
||||||
|
:deep(.el-menu:not(.el-menu--collapse)) {
|
||||||
|
.el-menu-item span,
|
||||||
|
.el-sub-menu__title span {
|
||||||
|
max-width: 1000px;
|
||||||
|
width: auto;
|
||||||
|
opacity: 1;
|
||||||
|
transition: max-width 0.3s 0.15s, width 0.3s 0.15s, opacity 0.3s 0.2s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保图标不受影响
|
||||||
|
:deep(.el-menu-item .el-icon),
|
||||||
|
:deep(.el-sub-menu__title .el-icon) {
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
239
src/hooks/useCopy.ts
Normal file
239
src/hooks/useCopy.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
interface UseCopyOptions {
|
||||||
|
/**
|
||||||
|
* 是否显示成功提示
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
showSuccessMessage?: boolean
|
||||||
|
/**
|
||||||
|
* 是否显示失败提示
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
showErrorMessage?: boolean
|
||||||
|
/**
|
||||||
|
* 成功提示文案
|
||||||
|
* @default '复制成功'
|
||||||
|
*/
|
||||||
|
successMessage?: string
|
||||||
|
/**
|
||||||
|
* 失败提示文案
|
||||||
|
* @default '复制失败,请手动复制'
|
||||||
|
*/
|
||||||
|
errorMessage?: string
|
||||||
|
/**
|
||||||
|
* 成功提示持续时间(毫秒)
|
||||||
|
* @default 2000
|
||||||
|
*/
|
||||||
|
successDuration?: number
|
||||||
|
/**
|
||||||
|
* 失败提示持续时间(毫秒)
|
||||||
|
* @default 3000
|
||||||
|
*/
|
||||||
|
errorDuration?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制文本到剪贴板的 hooks
|
||||||
|
* @param options 配置选项
|
||||||
|
* @returns 复制函数和状态
|
||||||
|
*/
|
||||||
|
export default function useCopy(options: UseCopyOptions = {}) {
|
||||||
|
const {
|
||||||
|
showSuccessMessage = true,
|
||||||
|
showErrorMessage = true,
|
||||||
|
successMessage = '复制成功',
|
||||||
|
errorMessage = '复制失败,请手动复制',
|
||||||
|
successDuration = 2000,
|
||||||
|
errorDuration = 3000,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
// 复制状态
|
||||||
|
const isCopying = ref(false)
|
||||||
|
const lastCopiedText = ref<string>('')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 Clipboard API 复制(现代浏览器)
|
||||||
|
*/
|
||||||
|
const copyWithClipboardAPI = async (text: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
// 检查是否支持 Clipboard API
|
||||||
|
if (!navigator.clipboard || !navigator.clipboard.writeText) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Clipboard API copy failed:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 execCommand 复制(兼容旧浏览器)
|
||||||
|
*/
|
||||||
|
const copyWithExecCommand = (text: string): boolean => {
|
||||||
|
try {
|
||||||
|
// 创建一个临时的 textarea 元素
|
||||||
|
const textarea = document.createElement('textarea')
|
||||||
|
textarea.value = text
|
||||||
|
textarea.style.position = 'fixed'
|
||||||
|
textarea.style.left = '-9999px'
|
||||||
|
textarea.style.top = '-9999px'
|
||||||
|
textarea.style.opacity = '0'
|
||||||
|
textarea.setAttribute('readonly', 'readonly')
|
||||||
|
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
|
||||||
|
// 选中文本
|
||||||
|
textarea.select()
|
||||||
|
textarea.setSelectionRange(0, text.length) // 兼容移动端
|
||||||
|
|
||||||
|
// 执行复制命令
|
||||||
|
const successful = document.execCommand('copy')
|
||||||
|
|
||||||
|
// 清理临时元素
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
|
||||||
|
return successful
|
||||||
|
} catch (error) {
|
||||||
|
console.error('execCommand copy failed:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制文本到剪贴板
|
||||||
|
* @param text 要复制的文本
|
||||||
|
* @returns Promise<boolean> 是否复制成功
|
||||||
|
*/
|
||||||
|
const copy = async (text: string): Promise<boolean> => {
|
||||||
|
if (!text) {
|
||||||
|
if (showErrorMessage) {
|
||||||
|
ElMessage({
|
||||||
|
message: '复制内容不能为空',
|
||||||
|
type: 'warning',
|
||||||
|
duration: errorDuration,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
isCopying.value = true
|
||||||
|
lastCopiedText.value = text
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 优先使用 Clipboard API
|
||||||
|
let success = await copyWithClipboardAPI(text)
|
||||||
|
|
||||||
|
// 如果 Clipboard API 失败,降级到 execCommand
|
||||||
|
if (!success) {
|
||||||
|
success = copyWithExecCommand(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
if (showSuccessMessage) {
|
||||||
|
ElMessage({
|
||||||
|
message: successMessage,
|
||||||
|
type: 'success',
|
||||||
|
duration: successDuration,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
// 两种方法都失败,尝试最后的方法:创建一个选择区域
|
||||||
|
const fallbackSuccess = fallbackCopy(text)
|
||||||
|
if (!fallbackSuccess && showErrorMessage) {
|
||||||
|
ElMessage({
|
||||||
|
message: errorMessage,
|
||||||
|
type: 'error',
|
||||||
|
duration: errorDuration,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return fallbackSuccess
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Copy failed:', error)
|
||||||
|
if (showErrorMessage) {
|
||||||
|
ElMessage({
|
||||||
|
message: errorMessage,
|
||||||
|
type: 'error',
|
||||||
|
duration: errorDuration,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
isCopying.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 备用复制方法:创建选择区域
|
||||||
|
*/
|
||||||
|
const fallbackCopy = (text: string): boolean => {
|
||||||
|
try {
|
||||||
|
const range = document.createRange()
|
||||||
|
const selection = window.getSelection()
|
||||||
|
|
||||||
|
if (!selection) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建一个临时元素
|
||||||
|
const tempDiv = document.createElement('div')
|
||||||
|
tempDiv.textContent = text
|
||||||
|
tempDiv.style.position = 'fixed'
|
||||||
|
tempDiv.style.left = '-9999px'
|
||||||
|
tempDiv.style.top = '-9999px'
|
||||||
|
|
||||||
|
document.body.appendChild(tempDiv)
|
||||||
|
|
||||||
|
range.selectNodeContents(tempDiv)
|
||||||
|
selection.removeAllRanges()
|
||||||
|
selection.addRange(range)
|
||||||
|
|
||||||
|
const successful = document.execCommand('copy')
|
||||||
|
|
||||||
|
selection.removeAllRanges()
|
||||||
|
document.body.removeChild(tempDiv)
|
||||||
|
|
||||||
|
return successful
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fallback copy failed:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制当前选中的文本
|
||||||
|
*/
|
||||||
|
const copySelection = async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const selectedText = window.getSelection()?.toString() || ''
|
||||||
|
if (!selectedText) {
|
||||||
|
if (showErrorMessage) {
|
||||||
|
ElMessage({
|
||||||
|
message: '请先选择要复制的内容',
|
||||||
|
type: 'warning',
|
||||||
|
duration: errorDuration,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return await copy(selectedText)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Copy selection failed:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
copy,
|
||||||
|
copySelection,
|
||||||
|
isCopying,
|
||||||
|
lastCopiedText,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
52
src/mock/auth.ts
Normal file
52
src/mock/auth.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* 登录相关 Mock 数据
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock 登录响应
|
||||||
|
*/
|
||||||
|
export const getMockLoginResponse = (username: string, password: string) => {
|
||||||
|
// 模拟登录验证
|
||||||
|
if (username && password) {
|
||||||
|
return {
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
accessToken: 'mock_access_token_' + Date.now(),
|
||||||
|
refreshToken: 'mock_refresh_token_' + Date.now(),
|
||||||
|
expiresIn: 7200, // 2小时
|
||||||
|
userInfo: {
|
||||||
|
id: 1,
|
||||||
|
username: username,
|
||||||
|
name: username === 'admin' ? '管理员' : '普通用户',
|
||||||
|
role: username === 'admin' ? 'admin' : 'user',
|
||||||
|
avatar: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
msg: '登录成功',
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
code: 400,
|
||||||
|
data: null,
|
||||||
|
msg: '用户名或密码不能为空',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock 用户信息响应
|
||||||
|
*/
|
||||||
|
export const getMockUserInfoResponse = () => {
|
||||||
|
return {
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
username: 'admin',
|
||||||
|
name: '管理员',
|
||||||
|
role: 'admin',
|
||||||
|
avatar: '',
|
||||||
|
},
|
||||||
|
msg: '获取用户信息成功',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
97
src/mock/index.ts
Normal file
97
src/mock/index.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Mock 数据管理
|
||||||
|
* 用于开发阶段模拟后端接口返回的数据
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getMockMenuResponse } from './menu'
|
||||||
|
import { getMockLoginResponse, getMockUserInfoResponse } from './auth'
|
||||||
|
|
||||||
|
// 是否启用 Mock(可以通过环境变量控制)
|
||||||
|
export const ENABLE_MOCK = import.meta.env.VITE_USE_MOCK === 'true' || import.meta.env.DEV
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock API 响应映射
|
||||||
|
* key: API 路径
|
||||||
|
* value: 返回的 Mock 数据函数
|
||||||
|
*/
|
||||||
|
const mockApiMap: Record<string, (params?: any, data?: any) => any> = {
|
||||||
|
'/api/menus': () => getMockMenuResponse(),
|
||||||
|
'/api/auth/login': (_params?: any, data?: any) => {
|
||||||
|
return getMockLoginResponse(data?.username || '', data?.password || '')
|
||||||
|
},
|
||||||
|
'/api/user/info': () => getMockUserInfoResponse(),
|
||||||
|
// 可以在这里添加更多的 mock 接口
|
||||||
|
// '/api/user/list': getMockUserList,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Mock 数据
|
||||||
|
* @param url API 路径
|
||||||
|
* @param params 请求参数(GET)
|
||||||
|
* @param data 请求体(POST/PUT)
|
||||||
|
* @returns Mock 数据或 null
|
||||||
|
*/
|
||||||
|
export const getMockData = (url: string, params?: any, data?: any): any => {
|
||||||
|
if (!ENABLE_MOCK || !url) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 精确匹配
|
||||||
|
if (mockApiMap[url]) {
|
||||||
|
return mockApiMap[url](params, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模糊匹配(支持带查询参数的 URL)
|
||||||
|
const urlWithoutQuery = url.split('?')[0]
|
||||||
|
if (urlWithoutQuery && mockApiMap[urlWithoutQuery]) {
|
||||||
|
return mockApiMap[urlWithoutQuery](params, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否应该使用 Mock 数据
|
||||||
|
* @param url API 路径
|
||||||
|
* @returns 是否应该使用 Mock
|
||||||
|
*/
|
||||||
|
export const shouldUseMock = (url: string): boolean => {
|
||||||
|
if (!ENABLE_MOCK || !url) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 精确匹配
|
||||||
|
if (mockApiMap[url]) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模糊匹配(支持带查询参数的 URL)
|
||||||
|
const urlWithoutQuery = url.split('?')[0]
|
||||||
|
return !!(urlWithoutQuery && mockApiMap[urlWithoutQuery])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加 Mock 接口
|
||||||
|
* @param url API 路径
|
||||||
|
* @param mockFn Mock 数据函数
|
||||||
|
*/
|
||||||
|
export const addMockApi = (url: string, mockFn: (params?: any, data?: any) => any) => {
|
||||||
|
mockApiMap[url] = mockFn
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除 Mock 接口
|
||||||
|
* @param url API 路径
|
||||||
|
*/
|
||||||
|
export const removeMockApi = (url: string) => {
|
||||||
|
delete mockApiMap[url]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
getMockData,
|
||||||
|
shouldUseMock,
|
||||||
|
addMockApi,
|
||||||
|
removeMockApi,
|
||||||
|
ENABLE_MOCK,
|
||||||
|
}
|
||||||
|
|
||||||
309
src/mock/menu.ts
Normal file
309
src/mock/menu.ts
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
/**
|
||||||
|
* 菜单路由 Mock 数据
|
||||||
|
* 用于开发阶段模拟后端返回的菜单数据
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface MockMenuRoute {
|
||||||
|
path: string;
|
||||||
|
name?: string;
|
||||||
|
component?: string;
|
||||||
|
meta?: {
|
||||||
|
title?: string;
|
||||||
|
icon?: string;
|
||||||
|
requiresAuth?: boolean;
|
||||||
|
roles?: string[];
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
children?: MockMenuRoute[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock 菜单数据
|
||||||
|
* 根据实际后端接口返回的数据格式进行配置
|
||||||
|
*/
|
||||||
|
export const mockMenuData: MockMenuRoute[] = [
|
||||||
|
{
|
||||||
|
path: "/home",
|
||||||
|
name: "Home",
|
||||||
|
component: "Home", // 对应 @/pages/Home/index.vue
|
||||||
|
meta: {
|
||||||
|
title: "首页",
|
||||||
|
icon: "HomeFilled",
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ["admin", "user"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "business",
|
||||||
|
path: "/business",
|
||||||
|
meta: {
|
||||||
|
title: "商机管理",
|
||||||
|
icon: "",
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ["admin", "user"],
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: "clue",
|
||||||
|
path: "clue",
|
||||||
|
component: "@/pages/StageManage/organization/index.vue",
|
||||||
|
meta: {
|
||||||
|
title: "线索管理",
|
||||||
|
icon: "",
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ["admin", "user"],
|
||||||
|
},
|
||||||
|
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "customer",
|
||||||
|
path: "customer",
|
||||||
|
component: "@/pages/StageManage/organization/index.vue",
|
||||||
|
meta: {
|
||||||
|
title: "客户管理",
|
||||||
|
icon: "",
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ["admin", "user"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "studio",
|
||||||
|
path: "studio",
|
||||||
|
component: "@/pages/StageManage/organization/index.vue",
|
||||||
|
meta: {
|
||||||
|
title: "游戏与工作室",
|
||||||
|
icon: "",
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ["admin", "user"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "businessmanage",
|
||||||
|
path: "businessmanage",
|
||||||
|
component: "@/pages/StageManage/organization/index.vue",
|
||||||
|
meta: {
|
||||||
|
title: "商机管理",
|
||||||
|
icon: "",
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ["admin", "user"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "contract",
|
||||||
|
path: "/contract",
|
||||||
|
meta: {
|
||||||
|
title: "合同管理",
|
||||||
|
icon: "",
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ["admin", "user"],
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: "income",
|
||||||
|
path: "income",
|
||||||
|
component: "@/pages/StageManage/organization/index.vue",
|
||||||
|
meta: {
|
||||||
|
title: "收入合同",
|
||||||
|
icon: "",
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ["admin", "user"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "project",
|
||||||
|
path: "/project",
|
||||||
|
meta:{
|
||||||
|
title: "项目管理",
|
||||||
|
icon: "",
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ["admin", "user"],
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: "requirement",
|
||||||
|
path: "requirement",
|
||||||
|
component: "@/pages/StageManage/organization/index.vue",
|
||||||
|
meta: {
|
||||||
|
title: "需求管理",
|
||||||
|
icon: "",
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ["admin", "user"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "projectmanage",
|
||||||
|
path: "projectmanage",
|
||||||
|
component: "@/pages/StageManage/organization/index.vue",
|
||||||
|
meta: {
|
||||||
|
title: "项目管理",
|
||||||
|
icon: "",
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ["admin", "user"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "task",
|
||||||
|
path: "task",
|
||||||
|
component: "@/pages/StageManage/organization/index.vue",
|
||||||
|
meta: {
|
||||||
|
title: "任务管理",
|
||||||
|
icon: "",
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ["admin", "user"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "work",
|
||||||
|
path: "work",
|
||||||
|
component: "@/pages/StageManage/organization/index.vue",
|
||||||
|
meta: {
|
||||||
|
title: "工时管理",
|
||||||
|
icon: "",
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ["admin", "user"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "team",
|
||||||
|
path: "/team",
|
||||||
|
meta:{
|
||||||
|
title: "团队管理",
|
||||||
|
icon: "",
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ["admin", "user"],
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "recruit",
|
||||||
|
path: "/recruit",
|
||||||
|
meta:{
|
||||||
|
title: "招聘管理",
|
||||||
|
icon: "",
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ["admin", "user"],
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: "resume",
|
||||||
|
path: "resume",
|
||||||
|
component: "@/pages/StageManage/organization/index.vue",
|
||||||
|
meta: {
|
||||||
|
title: "流程管理",
|
||||||
|
icon: "",
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ["admin", "user"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "push",
|
||||||
|
path: "push",
|
||||||
|
component: "@/pages/StageManage/organization/index.vue",
|
||||||
|
meta: {
|
||||||
|
title: "推送管理",
|
||||||
|
icon: "",
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ["admin", "user"],
|
||||||
|
},
|
||||||
|
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "job",
|
||||||
|
path: "job",
|
||||||
|
component: "@/pages/StageManage/organization/index.vue",
|
||||||
|
meta: {
|
||||||
|
title: "主场岗位",
|
||||||
|
icon: "",
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ["admin", "user"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dashboard",
|
||||||
|
title: "dashboard",
|
||||||
|
path: "/stage",
|
||||||
|
meta: {
|
||||||
|
title: "后台管理",
|
||||||
|
icon: "",
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ["admin", "user"],
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "flow",
|
||||||
|
name: "flow",
|
||||||
|
component: "@/pages/StageManage/organization/index.vue",
|
||||||
|
meta: {
|
||||||
|
title: "流程管理",
|
||||||
|
icon: "",
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ["admin", "user"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "organization",
|
||||||
|
component: "@/pages/StageManage/organization/index.vue",
|
||||||
|
path: "organization",
|
||||||
|
meta: {
|
||||||
|
title: "组织管理",
|
||||||
|
icon: "",
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ["admin", "user"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "personnel",
|
||||||
|
path: "personnel",
|
||||||
|
component: "@/pages/StageManage/organization/index.vue",
|
||||||
|
meta: {
|
||||||
|
title: "人员管理",
|
||||||
|
icon: "",
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ["admin", "user"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "permission",
|
||||||
|
path: "permission",
|
||||||
|
component: "@/pages/StageManage/organization/index.vue",
|
||||||
|
meta: {
|
||||||
|
title: "权限管理",
|
||||||
|
icon: "",
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ["admin", "user"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dict",
|
||||||
|
path: "dict",
|
||||||
|
component: "@/pages/StageManage/organization/index.vue",
|
||||||
|
meta: {
|
||||||
|
title: "字典管理",
|
||||||
|
icon: "",
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ["admin", "user"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Mock 菜单数据的响应格式
|
||||||
|
* 模拟后端接口返回的数据结构
|
||||||
|
*/
|
||||||
|
export const getMockMenuResponse = () => {
|
||||||
|
return {
|
||||||
|
code: 0,
|
||||||
|
data: mockMenuData,
|
||||||
|
msg: "获取菜单成功",
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {reactive,ref,onMounted} from "vue"
|
import {reactive,ref,onMounted} from "vue"
|
||||||
|
|
||||||
defineOptions({})
|
defineOptions({name:'Home'})
|
||||||
</script>
|
</script>
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,223 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="mj-layout">
|
<div class="mj-layout">
|
||||||
<el-container>
|
<el-container>
|
||||||
<el-aside :width> 左侧菜单模块 </el-aside>
|
<el-aside :width="width">
|
||||||
|
<div class="mj-aside-content">
|
||||||
|
<!-- 顶部company公司标记 -->
|
||||||
|
<div class="mj-aside-company"></div>
|
||||||
|
<div class="mj-aside-menu">
|
||||||
|
<div class="mj-aside-title" v-show="!isCollapse">{{ topTitle }}</div>
|
||||||
|
<mjMenus class="mj-aside_menu" :isCollapse="isCollapse" :menuList="sideMenuList" :active-menu="selectedActiveMenu"
|
||||||
|
@menu-select="handleSideMenuSelect" />
|
||||||
|
</div>
|
||||||
|
<!-- 展开收缩左侧菜单按钮 -->
|
||||||
|
<div class="mj-collapse" @click="showCollapse"></div>
|
||||||
|
</div>
|
||||||
|
</el-aside>
|
||||||
<el-container>
|
<el-container>
|
||||||
<el-header>头部模块</el-header>
|
<el-header class="mj-header-content">
|
||||||
|
<mjMenus :menuList="topLevelMenuList" mode="horizontal" :active-menu="selectedTopMenu"
|
||||||
|
@menu-select="handleTopMenuSelect" />
|
||||||
|
</el-header>
|
||||||
<el-main>
|
<el-main>
|
||||||
内容区域模块
|
<!-- <card-item :list="[1,2,3,4,5,6]"/> -->
|
||||||
|
<router-view />
|
||||||
</el-main>
|
</el-main>
|
||||||
</el-container>
|
</el-container>
|
||||||
</el-container>
|
</el-container>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import mjMenus from '@/components/standardMenu/index.vue';
|
||||||
|
import { useUserStore } from '@/store'
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: "Layout",
|
name: "Layout",
|
||||||
});
|
});
|
||||||
const isCollapse = ref(false);
|
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
// 响应式断点(小屏阈值,小于此值视为小屏)
|
||||||
|
const BREAKPOINT = 1024;
|
||||||
|
|
||||||
|
// 屏幕宽度
|
||||||
|
const screenWidth = ref(window.innerWidth);
|
||||||
|
|
||||||
|
// 菜单收缩状态:完全根据屏幕大小自动判断
|
||||||
|
const isCollapse = computed(() => {
|
||||||
|
return screenWidth.value < BREAKPOINT;
|
||||||
|
});
|
||||||
|
|
||||||
const width = computed(() => {
|
const width = computed(() => {
|
||||||
return isCollapse.value ? "60px" : "240px";
|
return isCollapse.value ? "80px" : "224px";
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听窗口大小变化
|
||||||
|
const handleResize = () => {
|
||||||
|
screenWidth.value = window.innerWidth;
|
||||||
|
// isCollapse 是计算属性,会自动响应 screenWidth 的变化
|
||||||
|
};
|
||||||
|
|
||||||
|
// 展开收缩菜单(临时切换,窗口大小变化时会自动恢复)
|
||||||
|
const showCollapse = () => {
|
||||||
|
if (isCollapse.value) {
|
||||||
|
screenWidth.value = BREAKPOINT + 1;
|
||||||
|
} else {
|
||||||
|
screenWidth.value = BREAKPOINT - 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// 返回菜单数据
|
||||||
|
const menuList = computed(() => {
|
||||||
|
return userStore.routes || []
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取一级菜单数据(不包含 children)
|
||||||
|
const topLevelMenuList = computed(() => {
|
||||||
|
return menuList.value.map(item => {
|
||||||
|
const { children, ...rest } = item
|
||||||
|
return rest
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const topTitle = computed(() => {
|
||||||
|
return topLevelMenuList.value.find(path => path.path === selectedTopMenu.value)?.meta?.title || '-'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 当前选中的顶部菜单
|
||||||
|
const selectedTopMenu = ref<string>('')
|
||||||
|
const selectedActiveMenu = ref<string>('');
|
||||||
|
|
||||||
|
// 根据选中的顶部菜单,获取左侧菜单列表
|
||||||
|
const sideMenuList = computed(() => {
|
||||||
|
if (!selectedTopMenu.value) {
|
||||||
|
// 默认选中第一个有 children 的菜单
|
||||||
|
const firstMenuWithChildren = menuList.value.find(item => item.children && item.children.length > 0)
|
||||||
|
if (firstMenuWithChildren) {
|
||||||
|
return (firstMenuWithChildren.children || []).map(child=>{
|
||||||
|
const fullPath = child.path.startsWith('/')
|
||||||
|
? child.path
|
||||||
|
: `${firstMenuWithChildren.path}/${child.path}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
...child,
|
||||||
|
path: fullPath
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据选中的顶部菜单路径,找到对应的菜单项
|
||||||
|
const selectedMenu = menuList.value.find(item => item.path === selectedTopMenu.value)
|
||||||
|
if (selectedMenu && selectedMenu.children) {
|
||||||
|
return selectedMenu.children.map(child=>{
|
||||||
|
const fullPath = child.path.startsWith('/')
|
||||||
|
? child.path
|
||||||
|
: `${selectedMenu.path}/${child.path}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
...child,
|
||||||
|
path: fullPath
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
// 处理顶部菜单选中事件
|
||||||
|
const handleTopMenuSelect = (menuPath: string) => {
|
||||||
|
selectedTopMenu.value = menuPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// 左侧菜单选中事件
|
||||||
|
const handleSideMenuSelect = (menuPath: string) => {
|
||||||
|
selectedActiveMenu.value = menuPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化:默认选中第一个菜单
|
||||||
|
onMounted(() => {
|
||||||
|
if (topLevelMenuList.value.length > 0) {
|
||||||
|
const firstMenu = topLevelMenuList.value[0]
|
||||||
|
if (firstMenu && firstMenu.path) {
|
||||||
|
selectedTopMenu.value = firstMenu.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听窗口大小变化
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
// 初始化时根据屏幕大小设置菜单状态
|
||||||
|
handleResize();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 组件卸载时移除监听
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.mj-layout {
|
.mj-layout {
|
||||||
height: inherit;
|
height: inherit;
|
||||||
|
|
||||||
:deep(.el-container) {
|
:deep(.el-container) {
|
||||||
height: inherit;
|
height: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep(.el-aside) {
|
||||||
|
transition: width 0.3s;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
:deep(.el-main) {
|
:deep(.el-main) {
|
||||||
|
--el-main-padding:16px;
|
||||||
background-color: #f8fafc;
|
background-color: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mj-aside-content {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-right: 1px solid var(--mj-border-color);
|
||||||
|
|
||||||
|
.mj-aside-company {
|
||||||
|
height: var(--mj-menu-header-height);
|
||||||
|
border-bottom: 1px solid var(--mj-border-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mj-aside-menu {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
.mj-aside-title {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #888;
|
||||||
|
padding: 10px var(--el-menu-base-level-padding);
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mj-collapse {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(var(--mj-menu-header-height)/2 - 10px);
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 10;
|
||||||
|
box-sizing: border-box;
|
||||||
|
box-shadow: 0 1px 6px #0000001f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mj-header-content {
|
||||||
|
--el-header-padding: 0;
|
||||||
|
border-bottom: 1px solid var(--mj-border-color);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,14 +1,167 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="mj-login">
|
<div class="mj-login">
|
||||||
<el-button>登录</el-button>
|
<div class="login-container">
|
||||||
|
<div class="login-box">
|
||||||
|
<h2 class="login-title">系统登录</h2>
|
||||||
|
<el-form
|
||||||
|
ref="loginFormRef"
|
||||||
|
:model="loginForm"
|
||||||
|
:rules="loginRules"
|
||||||
|
label-width="0"
|
||||||
|
class="login-form"
|
||||||
|
>
|
||||||
|
<el-form-item prop="username">
|
||||||
|
<el-input
|
||||||
|
v-model="loginForm.username"
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
size="large"
|
||||||
|
prefix-icon="User"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item prop="password">
|
||||||
|
<el-input
|
||||||
|
v-model="loginForm.password"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
size="large"
|
||||||
|
prefix-icon="Lock"
|
||||||
|
show-password
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleLogin"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref, onMounted } from "vue";
|
import { reactive, ref } from "vue";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useRouter, useRoute } from "vue-router";
|
||||||
import { useLang } from "@/utils/lang";
|
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
|
||||||
const { t } = useI18n();
|
import { login } from "@/api";
|
||||||
const { changeLang } = useLang();
|
import { useUserStore } from "@/store";
|
||||||
|
import useTokenRefresh from "@/hooks/useTokenRefresh";
|
||||||
|
|
||||||
defineOptions({ name: "Login" });
|
defineOptions({ name: "Login" });
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const { setTokens } = useTokenRefresh(import.meta.env.VITE_APP_BASE_API || "");
|
||||||
|
|
||||||
|
const loginFormRef = ref<FormInstance>();
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const loginForm = reactive({
|
||||||
|
username: "admin",
|
||||||
|
password: "123456",
|
||||||
|
});
|
||||||
|
|
||||||
|
const loginRules: FormRules = {
|
||||||
|
username: [
|
||||||
|
{ required: true, message: "请输入用户名", trigger: "blur" },
|
||||||
|
{ min: 3, max: 20, message: "用户名长度在 3 到 20 个字符", trigger: "blur" },
|
||||||
|
],
|
||||||
|
password: [
|
||||||
|
{ required: true, message: "请输入密码", trigger: "blur" },
|
||||||
|
{ min: 6, max: 20, message: "密码长度在 6 到 20 个字符", trigger: "blur" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
if (!loginFormRef.value) return;
|
||||||
|
|
||||||
|
await loginFormRef.value.validate(async (valid) => {
|
||||||
|
if (!valid) return;
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await login({
|
||||||
|
username: loginForm.username,
|
||||||
|
password: loginForm.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.code === 0 && response.data) {
|
||||||
|
const { accessToken, refreshToken, expiresIn, userInfo } = response.data;
|
||||||
|
|
||||||
|
// 保存 token
|
||||||
|
setTokens({
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
expiresAt: Date.now() + (expiresIn || 7200) * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 保存用户信息
|
||||||
|
userStore.setToken(accessToken);
|
||||||
|
userStore.setUserInfo(userInfo);
|
||||||
|
|
||||||
|
ElMessage.success("登录成功");
|
||||||
|
|
||||||
|
// 跳转到首页或之前访问的页面
|
||||||
|
const redirect = (route.query.redirect as string) || "/";
|
||||||
|
router.push(redirect);
|
||||||
|
} else {
|
||||||
|
ElMessage.error(response.msg || "登录失败");
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Login error:", error);
|
||||||
|
ElMessage.error(error.msg || "登录失败,请稍后重试");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
<style lang="scss" scoped></style>
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.mj-login {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 20px;
|
||||||
|
|
||||||
|
.login-box {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 40px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
.login-button {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
182
src/pages/StageManage/organization/index.vue
Normal file
182
src/pages/StageManage/organization/index.vue
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<template>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<!-- 底部内容(组织架构+信息展示) -->
|
||||||
|
<div class="organization-content">
|
||||||
|
<div class="mj-organization-card org-tree">
|
||||||
|
<div class="org-tree-title">
|
||||||
|
<div class="mj-panel-title org-tree-head">组织架构</div>
|
||||||
|
<div class="org-tree-search">
|
||||||
|
<el-input
|
||||||
|
v-model="search"
|
||||||
|
:prefix-icon="Search"
|
||||||
|
placeholder="搜索部门或公司"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="org-tree-list">
|
||||||
|
<el-tree
|
||||||
|
:data="data"
|
||||||
|
:props="defaultProps"
|
||||||
|
@node-click="handleNodeClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="org-bottom-add">
|
||||||
|
<el-input v-model="addValue" placeholder="快速添加分公司...">
|
||||||
|
<template #suffix>
|
||||||
|
<el-button text type="primary" :icon="Plus"> </el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mj-organization-card organization-info">
|
||||||
|
<div class="mj-panel_header">
|
||||||
|
<el-tabs v-model="activeName">
|
||||||
|
<el-tab-pane label="基础信息" name="first"></el-tab-pane>
|
||||||
|
<el-tab-pane label="动态日志" name="second"></el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Plus, Search } from "@element-plus/icons-vue";
|
||||||
|
import stageBreadcrumbs from '@/components/stageBreadcrumbs/index.vue';
|
||||||
|
defineOptions({ name: "Organization" });
|
||||||
|
|
||||||
|
const addValue = ref("");
|
||||||
|
const search = ref("");
|
||||||
|
|
||||||
|
const activeName = ref("first");
|
||||||
|
|
||||||
|
|
||||||
|
interface Tree {
|
||||||
|
label: string;
|
||||||
|
children?: Tree[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNodeClick = (data: Tree) => {
|
||||||
|
console.log(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const data: Tree[] = [
|
||||||
|
{
|
||||||
|
label: "Level one 1",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: "Level two 1-1",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: "Level three 1-1-1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Level one 2",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: "Level two 2-1",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: "Level three 2-1-1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Level two 2-2",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: "Level three 2-2-1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Level one 3",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: "Level two 3-1",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: "Level three 3-1-1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Level two 3-2",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: "Level three 3-2-1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
children: "children",
|
||||||
|
label: "label",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.mj-organization {
|
||||||
|
.mj-organization-card {
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid #e2e8f099;
|
||||||
|
background-color: #fff;
|
||||||
|
margin-top: $mj-padding-standard;
|
||||||
|
box-shadow: 0 0 6px #e9e8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.organization-tabs{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.organization-content {
|
||||||
|
display: flex;
|
||||||
|
height: 400px;
|
||||||
|
gap: 16px;
|
||||||
|
.org-tree {
|
||||||
|
flex: 0 0 280px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
.org-tree-title,
|
||||||
|
.org-bottom-add {
|
||||||
|
padding: $mj-padding-standard;
|
||||||
|
background-color: #fcfdfe;
|
||||||
|
}
|
||||||
|
.org-tree-title {
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
}
|
||||||
|
.org-tree-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: $mj-padding-standard/2 $mj-padding-standard;
|
||||||
|
}
|
||||||
|
.org-bottom-add {
|
||||||
|
border-top: 1px solid #f1f5f9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.organization-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,6 +3,7 @@ import type {AxiosInstance, AxiosRequestConfig, AxiosResponse} from 'axios';
|
|||||||
import useTokenRefresh from "@/hooks/useTokenRefresh";
|
import useTokenRefresh from "@/hooks/useTokenRefresh";
|
||||||
import 'element-plus/es/components/notification/style/css'
|
import 'element-plus/es/components/notification/style/css'
|
||||||
import { ElNotification } from 'element-plus'
|
import { ElNotification } from 'element-plus'
|
||||||
|
import { getMockData, shouldUseMock } from '@/mock' //mock数据信息
|
||||||
|
|
||||||
const baseUrl = import.meta.env.VITE_APP_BASE_API;
|
const baseUrl = import.meta.env.VITE_APP_BASE_API;
|
||||||
|
|
||||||
@@ -72,7 +73,6 @@ class HttpRequest {
|
|||||||
instance.interceptors.response.use(
|
instance.interceptors.response.use(
|
||||||
async (response: AxiosResponse<ApiResponse>) => {
|
async (response: AxiosResponse<ApiResponse>) => {
|
||||||
const { data: responseData, status } = response;
|
const { data: responseData, status } = response;
|
||||||
const originalRequest = response.config as AxiosRequestConfig & { _retry?: boolean };
|
|
||||||
|
|
||||||
switch (responseData.code) {
|
switch (responseData.code) {
|
||||||
case 0:
|
case 0:
|
||||||
@@ -193,6 +193,24 @@ class HttpRequest {
|
|||||||
|
|
||||||
// 通用请求方法
|
// 通用请求方法
|
||||||
public request<T = any>(config: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
public request<T = any>(config: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||||
|
// 检查是否应该使用 Mock 数据
|
||||||
|
const requestUrl = config.url || '';
|
||||||
|
|
||||||
|
// 优先使用请求 URL(通常是相对路径,如 /api/menus)
|
||||||
|
// 这样可以直接匹配 mockApiMap 中的 key
|
||||||
|
if (shouldUseMock(requestUrl)) {
|
||||||
|
const mockData = getMockData(requestUrl, config.params, config.data);
|
||||||
|
if (mockData) {
|
||||||
|
console.log(`[Mock] 使用 Mock 数据: ${requestUrl}`, mockData);
|
||||||
|
// 模拟网络延迟
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve(mockData as ApiResponse<T>);
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const instance = this.getInstance(config);
|
const instance = this.getInstance(config);
|
||||||
return instance(config).catch((error) => {
|
return instance(config).catch((error) => {
|
||||||
// 统一错误处理
|
// 统一错误处理
|
||||||
|
|||||||
@@ -1,16 +1,204 @@
|
|||||||
import { createWebHistory, createRouter } from 'vue-router'
|
import { createWebHistory, createRouter, type RouteRecordRaw } from 'vue-router'
|
||||||
|
import { useUserStore } from '@/store'
|
||||||
|
import { getRouteMenus } from '@/api'
|
||||||
|
import useTokenRefresh from '@/hooks/useTokenRefresh'
|
||||||
|
|
||||||
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 routes = [
|
|
||||||
{ path: '/', component: HomeView },
|
const baseUrl = import.meta.env.VITE_APP_BASE_API || '';
|
||||||
{ path: '/login', component: Login },
|
|
||||||
|
// 基础路由(不需要权限验证)
|
||||||
|
const constantRoutes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'Login',
|
||||||
|
component: Login,
|
||||||
|
meta: {
|
||||||
|
title: '登录',
|
||||||
|
requiresAuth: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// 动态路由(需要权限验证)
|
||||||
|
const asyncRoutes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'Layout',
|
||||||
|
component: HomeView,
|
||||||
|
redirect: '/home',
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
},
|
||||||
|
children: [] as RouteRecordRaw[],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes,
|
routes: [...constantRoutes, ...asyncRoutes],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 白名单路由(不需要登录即可访问)
|
||||||
|
const whiteList = ['/login']
|
||||||
|
|
||||||
|
// 1. 预先加载所有可能的页面组件
|
||||||
|
// 这里的路径模式要覆盖到你所有的业务组件
|
||||||
|
const modules = import.meta.glob('@/pages/**/*.vue')
|
||||||
|
|
||||||
|
const loadComponent = (componentPath: string) => {
|
||||||
|
if (!componentPath) return null
|
||||||
|
|
||||||
|
// 统一路径格式处理
|
||||||
|
let fullPath = ''
|
||||||
|
|
||||||
|
if (componentPath.startsWith('@/')) {
|
||||||
|
fullPath = componentPath
|
||||||
|
} else if (componentPath.includes('/')) {
|
||||||
|
// 补全路径,确保以 @/pages 开头
|
||||||
|
fullPath = componentPath.startsWith('pages/') ? `@/${componentPath}` : `@/pages/${componentPath}`
|
||||||
|
} else {
|
||||||
|
// 补全 index.vue
|
||||||
|
fullPath = `@/pages/${componentPath}/index.vue`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重点:将 @/ 转换为 /src/,因为 glob 默认生成的 key 是相对于项目根目录的
|
||||||
|
const key = fullPath.replace('@/', '/src/')
|
||||||
|
|
||||||
|
// 从 modules 中查找对应的导入函数
|
||||||
|
if (modules[key]) {
|
||||||
|
return modules[key]
|
||||||
|
} else {
|
||||||
|
console.error(`未找到组件文件: ${key}. 请检查路径或大小写。`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将后端返回的路由数据转换为 Vue Router 路由
|
||||||
|
const transformRoutes = (routes: any[]): RouteRecordRaw[] => {
|
||||||
|
return routes.map((route) => {
|
||||||
|
const component = route.component ? loadComponent(route.component) : undefined
|
||||||
|
// 构建基础路由对象
|
||||||
|
const routeRecord: any = {
|
||||||
|
path: route.path,
|
||||||
|
name: route.name || route.path,
|
||||||
|
meta: {
|
||||||
|
title: route.meta?.title || route.title || route.name,
|
||||||
|
icon: route.meta?.icon || route.icon,
|
||||||
|
requiresAuth: route.meta?.requiresAuth !== false, // 默认需要权限
|
||||||
|
roles: route.meta?.roles || route.roles,
|
||||||
|
...route.meta,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有组件,添加组件属性
|
||||||
|
if (component) {
|
||||||
|
routeRecord.component = component
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理子路由
|
||||||
|
if (route.children && route.children.length > 0) {
|
||||||
|
routeRecord.children = transformRoutes(route.children)
|
||||||
|
}
|
||||||
|
|
||||||
|
return routeRecord as RouteRecordRaw
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加动态路由
|
||||||
|
const addDynamicRoutes = async () => {
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
// 如果路由已加载,直接返回
|
||||||
|
if (userStore.isRoutesLoaded) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 从后端获取路由菜单数据
|
||||||
|
const response = await getRouteMenus()
|
||||||
|
if (response.code === 0 && response.data) {
|
||||||
|
// 转换路由数据
|
||||||
|
const dynamicRoutes = transformRoutes(Array.isArray(response.data) ? response.data : [response.data])
|
||||||
|
// 将动态路由添加到 Layout 的 children 中
|
||||||
|
const layoutRoute = router.getRoutes().find(route => route.name === 'Layout')
|
||||||
|
|
||||||
|
console.log('Layout route:', layoutRoute,dynamicRoutes)
|
||||||
|
if (layoutRoute) {
|
||||||
|
dynamicRoutes.forEach(route => {
|
||||||
|
router.addRoute('Layout', route)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// 如果找不到 Layout 路由,直接添加到根路由
|
||||||
|
dynamicRoutes.forEach(route => {
|
||||||
|
router.addRoute(route)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存路由数据到 store
|
||||||
|
userStore.setRoutes(response.data)
|
||||||
|
|
||||||
|
// 标记路由已加载
|
||||||
|
userStore.isRoutesLoaded = true
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load routes:', error)
|
||||||
|
// 如果获取路由失败,清除用户数据并跳转到登录页
|
||||||
|
userStore.clearUserData()
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 路由导航守卫
|
||||||
|
router.beforeEach(async (to, _from, next) => {
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const { getAccessToken } = useTokenRefresh(baseUrl)
|
||||||
|
|
||||||
|
// 获取 token
|
||||||
|
const token = getAccessToken() || userStore.token
|
||||||
|
|
||||||
|
// 如果已登录,更新 store 中的 token
|
||||||
|
if (token) {
|
||||||
|
userStore.setToken(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断是否在白名单中
|
||||||
|
if (whiteList.includes(to.path)) {
|
||||||
|
// 如果在白名单中且已登录,重定向到首页
|
||||||
|
if (token) {
|
||||||
|
next('/')
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 需要登录验证
|
||||||
|
if (!token) {
|
||||||
|
// 未登录,重定向到登录页
|
||||||
|
next({
|
||||||
|
path: '/login',
|
||||||
|
query: { redirect: to.fullPath }, // 保存当前路径,登录后可以跳转回来
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已登录,检查路由是否已加载
|
||||||
|
if (!userStore.isRoutesLoaded) {
|
||||||
|
try {
|
||||||
|
// 加载动态路由
|
||||||
|
await addDynamicRoutes()
|
||||||
|
// 路由加载完成后,重新导航到目标路由
|
||||||
|
next({ ...to, replace: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Route loading error:', error)
|
||||||
|
next('/login')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 路由已加载,直接放行
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
@@ -1,12 +1,63 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
|
import useTokenRefresh from "@/hooks/useTokenRefresh";
|
||||||
|
|
||||||
|
const baseUrl = import.meta.env.VITE_APP_BASE_API || "";
|
||||||
|
|
||||||
|
interface UserInfo {
|
||||||
|
name?: string;
|
||||||
|
role?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RouteMenu {
|
||||||
|
path: string;
|
||||||
|
name?: string;
|
||||||
|
component?: string;
|
||||||
|
meta?: {
|
||||||
|
title?: string;
|
||||||
|
icon?: string;
|
||||||
|
requiresAuth?: boolean;
|
||||||
|
roles?: string[];
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
children?: RouteMenu[];
|
||||||
|
}
|
||||||
|
|
||||||
const useUserStore = defineStore("user", {
|
const useUserStore = defineStore("user", {
|
||||||
state: () => {
|
state: () => {
|
||||||
|
const { getAccessToken } = useTokenRefresh(baseUrl);
|
||||||
return {
|
return {
|
||||||
name: "user",
|
token: getAccessToken() || "",
|
||||||
|
userInfo: {} as UserInfo,
|
||||||
|
routes: [] as RouteMenu[],
|
||||||
|
isRoutesLoaded: false, // 标记路由是否已加载
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
getters: {
|
||||||
|
isLoggedIn: (state) => {
|
||||||
|
return !!state.token;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
setToken(token: string) {
|
||||||
|
this.token = token;
|
||||||
|
},
|
||||||
|
setUserInfo(userInfo: UserInfo) {
|
||||||
|
this.userInfo = userInfo;
|
||||||
|
},
|
||||||
|
setRoutes(routes: RouteMenu[]) {
|
||||||
|
this.routes = routes;
|
||||||
|
this.isRoutesLoaded = true;
|
||||||
|
},
|
||||||
|
clearUserData() {
|
||||||
|
this.token = "";
|
||||||
|
this.userInfo = {};
|
||||||
|
this.routes = [];
|
||||||
|
this.isRoutesLoaded = false;
|
||||||
|
const { clearTokens } = useTokenRefresh(baseUrl);
|
||||||
|
clearTokens();
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
export default useUserStore;
|
export default useUserStore;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
@use './element.scss' as *;
|
@use './element.scss' as *;
|
||||||
|
@use './stage.scss' as *;
|
||||||
|
|
||||||
html,body{
|
html,body{
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -8,4 +9,14 @@ html,body{
|
|||||||
|
|
||||||
#app{
|
#app{
|
||||||
height: inherit;
|
height: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
:root{
|
||||||
|
--mj-menu-header-height:#{$mj-menu-header-height};
|
||||||
|
--mj-border-color:#{$mj-border-color};
|
||||||
|
--mj-padding-standard:#{$mj-padding-standard};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -38,4 +38,11 @@
|
|||||||
.el-dialog__footer{
|
.el-dialog__footer{
|
||||||
padding: var(--el-dialog-inset-padding-primary);
|
padding: var(--el-dialog-inset-padding-primary);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 全局重新element相关样式
|
||||||
|
.el-input{
|
||||||
|
--el-border-radius-base:10px;
|
||||||
|
--el-border-color:#E2E8F0;
|
||||||
}
|
}
|
||||||
@@ -1 +1,5 @@
|
|||||||
|
|
||||||
|
$mj-menu-header-height:60px;
|
||||||
|
$mj-border-color:#e5e7eb;
|
||||||
|
$mj-padding-standard:16px;
|
||||||
|
|
||||||
|
|||||||
30
src/styles/stage.scss
Normal file
30
src/styles/stage.scss
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
|
||||||
|
|
||||||
|
.mj-panel-title{
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: bold;
|
||||||
|
color:#1D293D;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
&::before{
|
||||||
|
content:"";
|
||||||
|
width: 4px;
|
||||||
|
height: 16px;
|
||||||
|
background-color: #155DFC;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-right: 8px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mj-panel_header{
|
||||||
|
height: 54px;
|
||||||
|
padding: 0 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-bottom: 1px solid #F1F5F9;
|
||||||
|
.el-tabs{
|
||||||
|
--el-tabs-header-height:54px;
|
||||||
|
--el-border-color-light:transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/utils/download.ts
Normal file
1
src/utils/download.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// 下载数据模块
|
||||||
@@ -10,7 +10,10 @@
|
|||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user