fix:完善相关页面
This commit is contained in:
14
components.d.ts
vendored
14
components.d.ts
vendored
@@ -11,6 +11,7 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
CardItem: typeof import('./src/components/cardItem/index.vue')['default']
|
||||
ElAside: typeof import('element-plus/es')['ElAside']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
@@ -18,14 +19,27 @@ declare module 'vue' {
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
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']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElMain: typeof import('element-plus/es')['ElMain']
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
||||
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']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
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']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,3 +4,18 @@ import request from '@/request';
|
||||
export const getUserList = (params?: any) => {
|
||||
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">
|
||||
import {reactive,ref,onMounted} from "vue"
|
||||
|
||||
defineOptions({})
|
||||
defineOptions({name:'Home'})
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
|
||||
|
||||
@@ -1,34 +1,223 @@
|
||||
<template>
|
||||
<div class="mj-layout">
|
||||
<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-header>头部模块</el-header>
|
||||
<el-header class="mj-header-content">
|
||||
<mjMenus :menuList="topLevelMenuList" mode="horizontal" :active-menu="selectedTopMenu"
|
||||
@menu-select="handleTopMenuSelect" />
|
||||
</el-header>
|
||||
<el-main>
|
||||
内容区域模块
|
||||
<!-- <card-item :list="[1,2,3,4,5,6]"/> -->
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import mjMenus from '@/components/standardMenu/index.vue';
|
||||
import { useUserStore } from '@/store'
|
||||
defineOptions({
|
||||
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(() => {
|
||||
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>
|
||||
<style lang="scss" scoped>
|
||||
.mj-layout {
|
||||
height: inherit;
|
||||
|
||||
:deep(.el-container) {
|
||||
height: inherit;
|
||||
}
|
||||
|
||||
:deep(.el-aside) {
|
||||
transition: width 0.3s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.el-main) {
|
||||
--el-main-padding:16px;
|
||||
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>
|
||||
|
||||
@@ -1,14 +1,167 @@
|
||||
<template>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, onMounted } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useLang } from "@/utils/lang";
|
||||
const { t } = useI18n();
|
||||
const { changeLang } = useLang();
|
||||
import { reactive, ref } from "vue";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
|
||||
import { login } from "@/api";
|
||||
import { useUserStore } from "@/store";
|
||||
import useTokenRefresh from "@/hooks/useTokenRefresh";
|
||||
|
||||
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>
|
||||
<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 'element-plus/es/components/notification/style/css'
|
||||
import { ElNotification } from 'element-plus'
|
||||
import { getMockData, shouldUseMock } from '@/mock' //mock数据信息
|
||||
|
||||
const baseUrl = import.meta.env.VITE_APP_BASE_API;
|
||||
|
||||
@@ -72,7 +73,6 @@ class HttpRequest {
|
||||
instance.interceptors.response.use(
|
||||
async (response: AxiosResponse<ApiResponse>) => {
|
||||
const { data: responseData, status } = response;
|
||||
const originalRequest = response.config as AxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
switch (responseData.code) {
|
||||
case 0:
|
||||
@@ -193,6 +193,24 @@ class HttpRequest {
|
||||
|
||||
// 通用请求方法
|
||||
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);
|
||||
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 HomeView from '@/pages/Layout/index.vue';
|
||||
const routes = [
|
||||
{ path: '/', component: HomeView },
|
||||
{ path: '/login', component: Login },
|
||||
|
||||
const baseUrl = import.meta.env.VITE_APP_BASE_API || '';
|
||||
|
||||
// 基础路由(不需要权限验证)
|
||||
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({
|
||||
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
|
||||
@@ -1,12 +1,63 @@
|
||||
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", {
|
||||
state: () => {
|
||||
const { getAccessToken } = useTokenRefresh(baseUrl);
|
||||
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;
|
||||
@@ -1,4 +1,5 @@
|
||||
@use './element.scss' as *;
|
||||
@use './stage.scss' as *;
|
||||
|
||||
html,body{
|
||||
height: 100%;
|
||||
@@ -9,3 +10,13 @@ html,body{
|
||||
#app{
|
||||
height: inherit;
|
||||
}
|
||||
|
||||
|
||||
:root{
|
||||
--mj-menu-header-height:#{$mj-menu-header-height};
|
||||
--mj-border-color:#{$mj-border-color};
|
||||
--mj-padding-standard:#{$mj-padding-standard};
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -39,3 +39,10 @@
|
||||
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,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user