fix:完善相关页面

This commit is contained in:
liangdong
2025-12-30 19:08:31 +08:00
parent fa4de6f71f
commit bfb6a1e500
22 changed files with 1886 additions and 23 deletions

14
components.d.ts vendored
View File

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

View File

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

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

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

View 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
View 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
View 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
View 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
View 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: "获取菜单成功",
};
};

View File

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

View File

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

View File

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

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

View File

@@ -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) => {
// 统一错误处理 // 统一错误处理

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +1,5 @@
$mj-menu-header-height:60px;
$mj-border-color:#e5e7eb;
$mj-padding-standard:16px;

30
src/styles/stage.scss Normal file
View 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
View File

@@ -0,0 +1 @@
// 下载数据模块

View File

@@ -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"]
} }