version: 20250815
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
VITE_APP_API_BASE_URL='http://10.97.245.96'
|
||||
VITE_APP_API_INT_BASE_URL='http://127.0.0.1'
|
||||
VITE_APP_API_EXT_BASE_URL= 'http://8.134.12.216:3005'
|
||||
# 32字节密钥(AES-256)
|
||||
VITE_APP_KEY='cms.SBU4.PE$20@2aXz3kL9pR7sQ4fT1vB'
|
||||
VITE_APP_KEY='cms.SBU4.PE$20@2aXz3kL9pR7sQ4fT1'
|
||||
|
||||
# 16字节向量
|
||||
VITE_APP_IV='m8Zp2x7cK9bF3dS5'
|
||||
@@ -1,7 +1,8 @@
|
||||
VITE_APP_AUTH_API='http://192.168.8.8:9000'
|
||||
VITE_APP_API_BASE_URL='http://192.168.8.8:9002'
|
||||
VITE_APP_API_BASE_URL='http://10.97.245.96'
|
||||
VITE_APP_API_INT_BASE_URL='http://10.97.245.96'
|
||||
VITE_APP_API_EXT_BASE_URL= 'http://8.134.12.216:3005'
|
||||
# 32字节密钥(AES-256)
|
||||
VITE_APP_KEY='cms.SBU4.PE$20@2aXz3kL9pR7sQ4fT1vB'
|
||||
VITE_APP_KEY='cms.SBU4.PE$20@2aXz3kL9pR7sQ4fT1'
|
||||
|
||||
# 16字节向量
|
||||
VITE_APP_IV='m8Zp2x7cK9bF3dS5'
|
||||
2
auto-imports.d.ts
vendored
2
auto-imports.d.ts
vendored
@@ -6,5 +6,5 @@
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
|
||||
const ElMessage: typeof import('element-plus/es')['ElMessage']
|
||||
}
|
||||
|
||||
20
components.d.ts
vendored
20
components.d.ts
vendored
@@ -9,35 +9,27 @@ export {}
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AuthPage: typeof import('./src/components/authentication/AuthPage.vue')['default']
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
ElAside: typeof import('element-plus/es')['ElAside']
|
||||
ElAvatar: typeof import('element-plus/es')['ElAvatar']
|
||||
ElBadge: typeof import('element-plus/es')['ElBadge']
|
||||
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
||||
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||
ElFooter: typeof import('element-plus/es')['ElFooter']
|
||||
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']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
|
||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
LoginForm: typeof import('./src/components/authentication/LoginForm.vue')['default']
|
||||
RegisterForm: typeof import('./src/components/authentication/RegisterForm.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
Sidebar: typeof import('./src/components/menu/Sidebar.vue')['default']
|
||||
SidebarItem: typeof import('./src/components/menu/SidebarItem.vue')['default']
|
||||
}
|
||||
export interface GlobalDirectives {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
<title>SBU4 I4.0</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 894 B |
@@ -80,7 +80,6 @@
|
||||
<script setup>
|
||||
import { ref, inject, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import LoginForm from './LoginForm.vue';
|
||||
import RegisterForm from './RegisterForm.vue';
|
||||
@@ -88,11 +87,12 @@ import { getLocale, setLocale } from '@/utils/storage';
|
||||
import request from '@/utils/api';
|
||||
import Cookies from 'js-cookie';
|
||||
import backgroundImage from '@/assets/background/vtech.png';
|
||||
import crypto from '@/utils/crypto';
|
||||
import router from '@/router';
|
||||
import { checkLoginStatus, clearLoginState } from '@/router';
|
||||
|
||||
// 初始化i18n
|
||||
const { locale, t } = useI18n();
|
||||
// 路由实例
|
||||
const router = useRouter();
|
||||
|
||||
// 主题相关
|
||||
const theme = inject('theme', null);
|
||||
@@ -105,18 +105,6 @@ const backgroundImageStyle = ref({});
|
||||
const loginLoading = ref(false);
|
||||
const currentLanguage = ref(getLocale() || 'zh-CN');
|
||||
|
||||
// 加密工具兜底方案
|
||||
let crypto;
|
||||
try {
|
||||
crypto = require('@/utils/crypto').default;
|
||||
} catch (e) {
|
||||
// 生产环境不输出警告
|
||||
crypto = {
|
||||
encrypt: (data) => data,
|
||||
decrypt: (data) => data
|
||||
};
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
locale.value = getLocale();
|
||||
@@ -129,10 +117,11 @@ const checkRememberedLogin = () => {
|
||||
const savedUser = localStorage.getItem('rememberedUser');
|
||||
if (savedUser) {
|
||||
try {
|
||||
const userData = JSON.parse(crypto.decrypt(savedUser));
|
||||
// 移除调试输出
|
||||
const decryptedData = crypto.decrypt(savedUser);
|
||||
const userData = JSON.parse(decryptedData);
|
||||
// 可以在这里自动填充用户名到登录表单
|
||||
} catch (e) {
|
||||
// 生产环境不输出错误
|
||||
console.error('解析记住的用户数据失败:', e);
|
||||
localStorage.removeItem('rememberedUser');
|
||||
}
|
||||
}
|
||||
@@ -146,7 +135,7 @@ const handleThemeChange = (newTheme) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 加载背景图片的方法
|
||||
// 加载背景图片
|
||||
const loadBackgroundImage = () => {
|
||||
try {
|
||||
const imgUrl = backgroundImage;
|
||||
@@ -185,14 +174,12 @@ const toggleMode = () => {
|
||||
isLoginMode.value = !isLoginMode.value;
|
||||
};
|
||||
|
||||
// 错误信息提取方法
|
||||
// 错误信息提取
|
||||
const getErrorMessage = (error) => {
|
||||
// 处理字符串错误
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
|
||||
// 处理Axios错误
|
||||
if (error && (error.name === 'AxiosError' || error.isAxiosError)) {
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
return t('auth.requestTimeout');
|
||||
@@ -213,19 +200,17 @@ const getErrorMessage = (error) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 处理普通对象错误
|
||||
if (error && typeof error === 'object') {
|
||||
return error.message || error.msg || t('auth.unknownError');
|
||||
}
|
||||
|
||||
// 兜底
|
||||
return t('auth.unknownError');
|
||||
};
|
||||
|
||||
|
||||
// 登录处理
|
||||
const handleLogin = async (formData) => {
|
||||
try {
|
||||
// 1. 验证表单数据是否存在
|
||||
// 验证表单数据
|
||||
if (!formData || !formData.username || !formData.password) {
|
||||
ElMessage.error(t('auth.usernameOrPasswordEmpty'));
|
||||
return;
|
||||
@@ -233,55 +218,75 @@ const handleLogin = async (formData) => {
|
||||
|
||||
loginLoading.value = true;
|
||||
|
||||
// 2. 发送请求
|
||||
// 发送登录请求
|
||||
const response = await request.postWithoutEncryption(
|
||||
'/api/i4/v1/auth/login.php',
|
||||
{
|
||||
login_name: formData.username,
|
||||
password: formData.password,
|
||||
rememberMe: formData.rememberMe
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
timeout: 10000
|
||||
}
|
||||
);
|
||||
|
||||
if (response && response.code === 200) {
|
||||
// 关键修复:强制解析响应为JSON对象
|
||||
let parsedResponse = response;
|
||||
if (typeof response === 'string') {
|
||||
// 移除可能的多余符号(如< >)
|
||||
const cleanedResponse = response.replace(/[<>]/g, '');
|
||||
try {
|
||||
parsedResponse = JSON.parse(cleanedResponse);
|
||||
} catch (e) {
|
||||
console.error('响应解析失败:', e);
|
||||
ElMessage.error('登录失败,响应格式错误');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const userInfo = response.data;
|
||||
// 关键修复:设置路由守卫依赖的登录状态
|
||||
// 使用解析后的响应判断
|
||||
console.log('解析后响应:', parsedResponse, 'BOOL:', parsedResponse?.code === 200);
|
||||
if (parsedResponse && parsedResponse.code === 200) {
|
||||
const userInfo = parsedResponse.data;
|
||||
console.log('用户信息:', userInfo);
|
||||
// 存储登录状态
|
||||
localStorage.setItem('isLoggedIn', 'true');
|
||||
// 存储token(如果后端返回了token)
|
||||
Cookies.set('access_token', userInfo.token || '');
|
||||
localStorage.setItem('userInfo', crypto.encrypt(JSON.stringify(userInfo)));
|
||||
|
||||
// 加密存储用户信息
|
||||
const encryptedUserInfo = crypto.encrypt(JSON.stringify(userInfo));
|
||||
localStorage.setItem('userInfo', encryptedUserInfo);
|
||||
|
||||
// 存储token
|
||||
Cookies.set('access_token', userInfo.token || '', {
|
||||
expires: formData.rememberMe ? 7 : 2/24, // 记住我时保存7天,否则2小时
|
||||
path: '/',
|
||||
});
|
||||
|
||||
// 处理记住我
|
||||
if (formData.rememberMe) {
|
||||
localStorage.setItem('rememberedUser', crypto.encrypt(JSON.stringify({
|
||||
const rememberedData = crypto.encrypt(JSON.stringify({
|
||||
username: formData.username,
|
||||
rememberMe: true
|
||||
})));
|
||||
}));
|
||||
localStorage.setItem('rememberedUser', rememberedData);
|
||||
} else {
|
||||
localStorage.removeItem('rememberedUser');
|
||||
}
|
||||
|
||||
ElMessage.success(t('auth.loginSuccess'));
|
||||
|
||||
// 改进跳转逻辑:使用路由实例的push并处理可能的异常
|
||||
// 验证登录状态并跳转
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await router.push('/main');
|
||||
// 跳转成功后可以刷新页面确保状态生效(可选)
|
||||
// window.location.reload();
|
||||
} catch (err) {
|
||||
console.error('路由跳转失败:', err);
|
||||
// 失败时的备选方案
|
||||
window.location.href = '/main';
|
||||
if (checkLoginStatus()) {
|
||||
await router.push('/');
|
||||
} else {
|
||||
throw new Error('登录状态验证失败');
|
||||
}
|
||||
}, 800);
|
||||
} catch (err) {
|
||||
console.error('跳转失败:', err);
|
||||
ElMessage.error('登录状态异常,请重试');
|
||||
clearLoginState();
|
||||
}
|
||||
}, 500);
|
||||
} else {
|
||||
const errorMsg = response?.message || t('auth.loginFailed');
|
||||
ElMessage.error(errorMsg);
|
||||
@@ -294,6 +299,11 @@ const handleLogin = async (formData) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 注册处理(如果需要实现)
|
||||
const handleRegister = async (formData) => {
|
||||
// 注册逻辑实现
|
||||
ElMessage.info('注册功能待实现');
|
||||
};
|
||||
|
||||
// 语言切换
|
||||
const toggleLanguage = (newLocale) => {
|
||||
@@ -304,7 +314,6 @@ const toggleLanguage = (newLocale) => {
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
position: relative;
|
||||
@@ -331,7 +340,7 @@ const toggleLanguage = (newLocale) => {
|
||||
height: 390px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 10px 25px -5px var(--color-shadow);
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
background-color: rgba(255, 255, 255, 0.001);
|
||||
backdrop-filter: blur(4px);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
:model="loginForm"
|
||||
:rules="loginRules"
|
||||
class="login-form"
|
||||
@keyup.enter.native="handleSubmit"
|
||||
>
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model="loginForm.username"
|
||||
:placeholder="$t('auth.usernamePlaceholder')"
|
||||
@keyup.enter.native="handleSubmit"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon class="el-input__icon"><User /></el-icon>
|
||||
@@ -22,6 +25,7 @@
|
||||
:placeholder="$t('auth.passwordPlaceholder')"
|
||||
type="password"
|
||||
show-password
|
||||
@keyup.enter.native="handleSubmit"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon class="el-input__icon"><Lock /></el-icon>
|
||||
|
||||
221
src/components/menu/Sidebar.vue
Normal file
221
src/components/menu/Sidebar.vue
Normal file
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<el-menu
|
||||
:default-active="defaultActive"
|
||||
class="el-menu-vertical"
|
||||
:collapse="isCollapse"
|
||||
:unique-opened="true"
|
||||
router
|
||||
@select="handleMenuSelect"
|
||||
>
|
||||
<!-- 折叠按钮 -->
|
||||
<el-button
|
||||
v-if="!isCollapse"
|
||||
icon="Expand"
|
||||
size="mini"
|
||||
class="collapse-btn"
|
||||
@click="$emit('toggle-collapse')"
|
||||
:title="t('common.collapseMenu')"
|
||||
/>
|
||||
<el-button
|
||||
v-else
|
||||
icon="Fold"
|
||||
size="mini"
|
||||
class="collapse-btn"
|
||||
@click="$emit('toggle-collapse')"
|
||||
:title="t('common.expandMenu')"
|
||||
/>
|
||||
|
||||
<template v-for="item in topLevelMenus" :key="item.id">
|
||||
<sidebar-item
|
||||
:item="item"
|
||||
:menu-data="rowData"
|
||||
:depth="1"
|
||||
/>
|
||||
</template>
|
||||
</el-menu>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, defineProps, defineEmits, watch, onMounted, inject } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import request from '@/utils/api';
|
||||
import SidebarItem from './SidebarItem.vue'
|
||||
|
||||
const props = defineProps({
|
||||
isCollapse: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
// 注入主题配置
|
||||
const theme = inject('theme', { currentTheme: 'light' });
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 获取路由实例
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const currentRouteFullPath = ref(route.fullPath)
|
||||
|
||||
// 监听路由变化
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
(newFullPath) => {
|
||||
currentRouteFullPath.value = newFullPath
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 处理菜单选中事件
|
||||
const handleMenuSelect = (index) => {
|
||||
const targetRoute = router.resolve(index)
|
||||
if (targetRoute.fullPath === route.fullPath) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const emit = defineEmits(['toggle-collapse'])
|
||||
|
||||
const rowData = ref([]) // 响应式菜单数据
|
||||
|
||||
// 获取菜单数据
|
||||
const getMenuData = async () => {
|
||||
try {
|
||||
const response = await request.postWithoutEncryption(
|
||||
'/api/i4/v1/get/menu.php',
|
||||
{
|
||||
login_name: 'NR1798' // 可替换为动态值
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json'
|
||||
},
|
||||
timeout: 10000
|
||||
}
|
||||
);
|
||||
|
||||
if (response && response.code === 200 && Array.isArray(response.data)) {
|
||||
return response.data;
|
||||
} else {
|
||||
const errorMsg = response?.message || t('common.unknownError');
|
||||
ElMessage.error(`${t('menu.fetchFailed')}: ${errorMsg}`);
|
||||
console.error('后端错误:', response);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = error.response?.data?.message
|
||||
|| error.message
|
||||
|| t('common.networkError');
|
||||
ElMessage.error(`${t('menu.fetchFailed')}: ${msg}`);
|
||||
console.error('获取菜单数据失败:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await getMenuData();
|
||||
if (data) {
|
||||
rowData.value = data;
|
||||
} else {
|
||||
ElMessage.warning(t('menu.noMenuData'));
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(t('menu.loadFailed'));
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
// 顶级菜单过滤
|
||||
const topLevelMenus = computed(() => {
|
||||
if (!Array.isArray(rowData.value)) return [];
|
||||
return rowData.value.filter(item => item.parent_id === 0);
|
||||
});
|
||||
|
||||
// 默认激活项计算
|
||||
const defaultActive = computed(() => {
|
||||
if (!Array.isArray(rowData.value) || !currentRouteFullPath.value) return '';
|
||||
|
||||
// 查找与当前路由匹配的菜单项
|
||||
const matchedItem = rowData.value.find(item => {
|
||||
if (!item.menu_url) return false;
|
||||
// 处理完整匹配和部分匹配
|
||||
return currentRouteFullPath.value === item.menu_url ||
|
||||
currentRouteFullPath.value.startsWith(item.menu_url + '/');
|
||||
});
|
||||
|
||||
return matchedItem ? (matchedItem.menu_url || String(matchedItem.id)) : '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 设置菜单项的高度 */
|
||||
.el-menu-vertical :deep(.el-menu-item),
|
||||
.el-menu-vertical :deep(.el-sub-menu__title) {
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
color: var(--color-text); /* 使用全局主题文本色 */
|
||||
}
|
||||
|
||||
/* 子菜单项的高度 */
|
||||
.el-menu-vertical :deep(.el-menu-item) {
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
height: 100vh;
|
||||
border-right: 1px solid var(--color-border); /* 使用全局主题边框色 */
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background-color: var(--color-surface); /* 使用全局主题表面色 */
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: -10px;
|
||||
z-index: 10;
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--color-surface); /* 使用全局主题表面色 */
|
||||
border: 1px solid var(--color-border); /* 使用全局主题边框色 */
|
||||
color: var(--color-text); /* 使用全局主题文本色 */
|
||||
}
|
||||
|
||||
/* 适配全局主题的菜单样式 */
|
||||
:deep(.el-menu) {
|
||||
background-color: var(--color-surface) !important;
|
||||
border-right-color: var(--color-border) !important;
|
||||
}
|
||||
|
||||
/* 关键修改:通过样式设置激活文本颜色,替代属性绑定 */
|
||||
:deep(.el-menu-item.is-active),
|
||||
:deep(.el-sub-menu__title.is-active) {
|
||||
color: var(--color-primary) !important;
|
||||
background-color: var(--color-surface-alt) !important;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item:hover:not(.is-active)) {
|
||||
background-color: var(--color-surface-alt) !important;
|
||||
}
|
||||
|
||||
:deep(.el-sub-menu__title:hover:not(.is-active)) {
|
||||
background-color: var(--color-surface-alt) !important;
|
||||
}
|
||||
|
||||
/* 菜单折叠/展开动画 */
|
||||
.el-menu-vertical {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
72
src/components/menu/SidebarItem.vue
Normal file
72
src/components/menu/SidebarItem.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<template v-if="depth > 10">
|
||||
<el-menu-item disabled>
|
||||
<span>{{ t('menu.menuTooDeep') }}</span>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
|
||||
<el-sub-menu v-else-if="hasChildren" :index="indexValue">
|
||||
<template #title>
|
||||
<span>{{ t(item.menu_name) }}</span>
|
||||
</template>
|
||||
<template v-for="child in children" :key="child.id">
|
||||
<sidebar-item
|
||||
:item="child"
|
||||
:menu-data="menuData"
|
||||
:depth="depth + 1"
|
||||
/>
|
||||
</template>
|
||||
</el-sub-menu>
|
||||
|
||||
<el-menu-item
|
||||
v-else
|
||||
:index="indexValue"
|
||||
:route="item.menu_url || undefined"
|
||||
>
|
||||
<span>{{ t(item.menu_name) }}</span>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({})
|
||||
},
|
||||
menuData: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => []
|
||||
},
|
||||
depth: {
|
||||
type: Number,
|
||||
default: 1
|
||||
}
|
||||
})
|
||||
|
||||
// 计算索引值,优先使用menu_url,否则使用id
|
||||
const indexValue = computed(() => {
|
||||
return props.item.menu_url || String(props.item.id);
|
||||
})
|
||||
|
||||
// 获取子菜单
|
||||
const children = computed(() => {
|
||||
return props.menuData
|
||||
.filter(child =>
|
||||
child.parent_id === props.item.id &&
|
||||
child.id !== props.item.id // 防止自引用
|
||||
)
|
||||
.sort((a, b) => a.id - b.id); // 按ID排序
|
||||
})
|
||||
|
||||
// 判断是否有子菜单
|
||||
const hasChildren = computed(() => {
|
||||
return children.value.length > 0;
|
||||
})
|
||||
</script>
|
||||
@@ -10,8 +10,8 @@ export default {
|
||||
passwordMinLength: 'Password must be at least 6 characters',
|
||||
confirmPassword: 'Confirm Password',
|
||||
passwordMismatch: 'Passwords do not match',
|
||||
usernamePlaceholder: 'User name',
|
||||
passwordPlaceholder: 'Password',
|
||||
usernamePlaceholder: 'Enter username',
|
||||
passwordPlaceholder: 'Enter password',
|
||||
loginBtn: 'Login',
|
||||
confirmPasswordPlaceholder: 'Please confirm your password',
|
||||
registerBtn: 'Register',
|
||||
@@ -21,16 +21,95 @@ export default {
|
||||
invalidCredentials: 'Invalid username or password',
|
||||
serverError: 'Server error, please try again later',
|
||||
networkError: 'Network error, please check your connection',
|
||||
unknownError: 'An unknown error occurred, please try again'
|
||||
unknownError: 'An unknown error occurred, please try again',
|
||||
requestTimeout: 'Request timed out, please try again later',
|
||||
unauthorized: 'Unauthorized access, please login again',
|
||||
forbidden: 'Access forbidden, insufficient permissions',
|
||||
notFound: 'Requested resource not found',
|
||||
requestFailed: 'Request processing failed',
|
||||
usernameExists: 'Username already exists',
|
||||
emailExists: 'Email already registered',
|
||||
invalidUsername: 'Invalid username format',
|
||||
invalidPassword: 'Password format does not meet requirements',
|
||||
registerSuccess: 'Registration successful',
|
||||
registerFailed: 'Registration failed, please try again'
|
||||
},
|
||||
common: {
|
||||
login: 'Login',
|
||||
register: 'Register',
|
||||
switchLang: 'Switch Language'
|
||||
switchLang: 'Switch Language',
|
||||
languageChanged: 'Language switched successfully',
|
||||
close: 'Close',
|
||||
confirm: 'Confirm',
|
||||
cancel: 'Cancel',
|
||||
refresh: 'Refresh',
|
||||
collapseMenu: 'Collapse Menu',
|
||||
expandMenu: 'Expand Menu',
|
||||
operationSuccess: 'Operation successful',
|
||||
operationFailed: 'Operation failed',
|
||||
pleaseWait: 'Please wait...',
|
||||
back: 'Back',
|
||||
save: 'Save',
|
||||
delete: 'Delete',
|
||||
edit: 'Edit',
|
||||
add: 'Add',
|
||||
search: 'Search',
|
||||
reset: 'Reset'
|
||||
},
|
||||
el: {
|
||||
form: {
|
||||
required: 'Please fill in the required field'
|
||||
}
|
||||
},
|
||||
menu: {
|
||||
settings: 'Settings',
|
||||
manage: 'Management',
|
||||
equipment: 'Equipment',
|
||||
purchase: 'Purchase',
|
||||
spare: 'Spare Parts',
|
||||
maintenance: 'Maintenance',
|
||||
report: 'Reports',
|
||||
capacity: 'Capacity',
|
||||
applet: 'Mini Program',
|
||||
useringroup: 'User Groups',
|
||||
userindepartment: 'User Departments',
|
||||
passwordreset: 'Password Reset',
|
||||
users: 'Users',
|
||||
groups: 'Groups',
|
||||
departments: 'Departments',
|
||||
instruments: 'Instruments',
|
||||
fixtures: 'Fixtures',
|
||||
racks: 'Aging Racks',
|
||||
testers: 'Test Stations',
|
||||
purlist: 'Purchase List',
|
||||
splist: 'Spare Parts List',
|
||||
dashboard: 'Dashboard',
|
||||
home: 'Home',
|
||||
help: 'Help Center',
|
||||
dailyReport: 'Daily Report',
|
||||
monthlyReport: 'Monthly Report',
|
||||
annualReport: 'Annual Report',
|
||||
faultReport: 'Fault Report',
|
||||
systemBasic: 'Basic Settings',
|
||||
security: 'Security Settings',
|
||||
notifications: 'Notification Settings',
|
||||
logs: 'System Logs',
|
||||
fetchFailed: 'Failed to fetch menu',
|
||||
noMenuData: 'No menu data available',
|
||||
loadFailed: 'Failed to load menu information',
|
||||
noPermission: 'No access permission',
|
||||
menuTooDeep: "Menu level too deep"
|
||||
},
|
||||
main: {
|
||||
welcome: 'Welcome to the system',
|
||||
contentDescription: 'Please select a menu item on the left to proceed'
|
||||
},
|
||||
user: {
|
||||
profile: "Profile",
|
||||
logout: "Logout",
|
||||
notifications: "Notifications",
|
||||
defaultName: "User",
|
||||
defaultRole: "Regular User"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
export default {
|
||||
theme: {
|
||||
light: '亮色主题',
|
||||
dark: '暗色主题',
|
||||
custom: '自定义主题',
|
||||
themeSwitched: '已切换到{theme}主题'
|
||||
},
|
||||
auth: {
|
||||
loginTitle: '用户登录',
|
||||
registerTitle: '用户注册',
|
||||
@@ -24,17 +30,20 @@ export default {
|
||||
serverError: '服务器错误,请稍后再试',
|
||||
networkError: '网络连接错误,请检查网络',
|
||||
unknownError: '发生未知错误,请重试',
|
||||
// 新增错误类型翻译
|
||||
requestTimeout: '请求超时,请稍后重试',
|
||||
unauthorized: '未授权访问,请重新登录',
|
||||
forbidden: '禁止访问,没有权限',
|
||||
requestTimeout: '请求超时超时,请稍后重试',
|
||||
unauthorized: '未授权访问,请,请重新登录',
|
||||
forbidden: '禁止访问访问,没有权限',
|
||||
notFound: '请求的资源不存在',
|
||||
requestFailed: '请求处理失败',
|
||||
// 可能需要的其他注册相关提示
|
||||
usernameExists: '用户名已存在',
|
||||
emailExists: '邮箱已被注册',
|
||||
invalidUsername: '用户名格式不正确',
|
||||
invalidPassword: '密码格式不符合要求'
|
||||
invalidUsername: '用户名格式名格式不正确',
|
||||
invalidPassword: '密码格式不符合要求',
|
||||
sessionExpired: '登录状态已失效,请重新登录',
|
||||
loginSuccess: '登录成功',
|
||||
welcomeBack: '欢迎回来',
|
||||
logoutSuccess: '退出登录成功',
|
||||
logoutFailed: '退出登录失败'
|
||||
},
|
||||
common: {
|
||||
login: '登录',
|
||||
@@ -43,12 +52,63 @@ export default {
|
||||
languageChanged: '语言已切换',
|
||||
close: '关闭',
|
||||
confirm: '确认',
|
||||
cancel: '取消'
|
||||
cancel: '取消',
|
||||
refresh: '刷新',
|
||||
collapseMenu: '折叠菜单',
|
||||
expandMenu: '展开菜单',
|
||||
unknownError: '未知错误',
|
||||
networkError: '网络异常',
|
||||
languageChanged: '语言已切换'
|
||||
},
|
||||
el: {
|
||||
form: {
|
||||
required: '请输入必填项'
|
||||
}
|
||||
},
|
||||
menu: {
|
||||
settings: '设置',
|
||||
manage: '管理',
|
||||
equipment: '设备',
|
||||
purchase: '采购',
|
||||
spare: '备件',
|
||||
maintenance: '维护',
|
||||
report: '报告',
|
||||
capacity: '容量',
|
||||
applet: '小程序',
|
||||
useringroup: '用户组别',
|
||||
userindepartment: '用户部门',
|
||||
passwordreset: '密码重置',
|
||||
users: '用户',
|
||||
groups: '组别',
|
||||
departments: '部门',
|
||||
instruments: '仪器',
|
||||
fixtures: '夹具',
|
||||
racks: '老化架',
|
||||
testers: '测试位',
|
||||
purlist: '采购清单',
|
||||
splist: '备件清单',
|
||||
fetchFailed: '获取菜单失败',
|
||||
menuTooDeep: "菜单层级过深"
|
||||
},
|
||||
user: {
|
||||
profile: "个人信息",
|
||||
logout: "退出登录",
|
||||
notifications: "通知",
|
||||
defaultName: "用户",
|
||||
defaultRole: "普通用户",
|
||||
unknown: '未知用户',
|
||||
unauthorized: '未授权',
|
||||
defaultRole: '普通用户'
|
||||
},
|
||||
error: {
|
||||
noUserInfo: '未找到用户信息',
|
||||
invalidBase64: '加密数据格式无效',
|
||||
decryptFailed: '用户信息解密失败',
|
||||
jsonParseFailed: 'JSON解析失败',
|
||||
invalidUserInfo: '用户信息格式无效',
|
||||
loadUserFailed: '加载用户信息失败'
|
||||
},
|
||||
notification: {
|
||||
openCenter: '打开通知中心'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,58 +1,84 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import Cookies from 'js-cookie';
|
||||
import AuthPage from '../components/authentication/AuthPage.vue';
|
||||
import MainPage from '../views/mainpage/index.vue';
|
||||
import UutReport from '../views/uut/Report.vue';
|
||||
import UutHistory from '../views/uut/History.vue';
|
||||
import DashboardTest from '../views/dashboard/test.vue';
|
||||
import Profile from '../views/user/Profile.vue';
|
||||
import Testers from '../views/equipment/Testers.vue';
|
||||
|
||||
// 路由守卫:检查是否登录 - 优化版
|
||||
const requireAuth = (to, from, next) => {
|
||||
// 更可靠的登录状态检查
|
||||
// 登录状态检查工具函数
|
||||
const checkLoginStatus = () => {
|
||||
const isLoggedIn = localStorage.getItem('isLoggedIn') === 'true';
|
||||
const hasUserInfo = !!localStorage.getItem('userInfo');
|
||||
const hasToken = !!Cookies.get('access_token');
|
||||
return isLoggedIn && hasUserInfo && hasToken;
|
||||
};
|
||||
|
||||
if (isLoggedIn && hasUserInfo) {
|
||||
// 清除登录状态方法
|
||||
const clearLoginState = () => {
|
||||
localStorage.removeItem('isLoggedIn');
|
||||
localStorage.removeItem('userInfo');
|
||||
localStorage.removeItem('rememberedUser');
|
||||
Cookies.remove('access_token');
|
||||
};
|
||||
|
||||
// 路由守卫:需要登录的路由
|
||||
const requireAuth = (to, from, next) => {
|
||||
if (checkLoginStatus()) {
|
||||
next();
|
||||
} else {
|
||||
// 清除无效的登录状态
|
||||
localStorage.removeItem('isLoggedIn');
|
||||
next('/');
|
||||
clearLoginState();
|
||||
next('/auth');
|
||||
}
|
||||
};
|
||||
|
||||
// 路由配置
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
path: '/auth',
|
||||
name: 'Auth',
|
||||
component: AuthPage,
|
||||
beforeEnter: (to, from, next) => {
|
||||
const isLoggedIn = localStorage.getItem('isLoggedIn') === 'true';
|
||||
const hasUserInfo = !!localStorage.getItem('userInfo');
|
||||
|
||||
if (isLoggedIn && hasUserInfo) {
|
||||
next('/main');
|
||||
if (checkLoginStatus()) {
|
||||
next('/');
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/main',
|
||||
path: '/',
|
||||
name: 'Main',
|
||||
component: MainPage,
|
||||
beforeEnter: requireAuth
|
||||
beforeEnter: requireAuth,
|
||||
children: [
|
||||
{ path: '', redirect: '/dashboard' },
|
||||
{ path: 'dashboard', name: 'Dashboard', component: DashboardTest },
|
||||
{ path: 'uut/report', name: 'UutReport', component: UutReport },
|
||||
{ path: 'uut/history', name: 'UutHistory', component: UutHistory },
|
||||
{ path: 'profile', component: Profile },
|
||||
{ path: 'testers', component: Testers }
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/'
|
||||
name: 'NotFound',
|
||||
redirect: (to) => checkLoginStatus() ? '/' : '/auth'
|
||||
}
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
history: createWebHistory('/sbu4i4/'),
|
||||
routes,
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
return savedPosition || { top: 0 };
|
||||
}
|
||||
});
|
||||
|
||||
// 全局路由守卫,处理可能的登录状态异常
|
||||
// 全局路由守卫
|
||||
router.beforeEach((to, from, next) => {
|
||||
// 对所有需要授权的路由进行检查
|
||||
if (to.matched.some(record => record.beforeEnter === requireAuth)) {
|
||||
requireAuth(to, from, next);
|
||||
} else {
|
||||
@@ -60,4 +86,5 @@ router.beforeEach((to, from, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
// 导出路由和工具函数
|
||||
export { router as default, clearLoginState, checkLoginStatus };
|
||||
|
||||
53
src/utils/api/api.js
Normal file
53
src/utils/api/api.js
Normal file
@@ -0,0 +1,53 @@
|
||||
// 所有API接口路径集中管理
|
||||
export const ApiPaths = {
|
||||
// 仪器相关接口
|
||||
machine: {
|
||||
list: '/api/i4/v1/get/machines.php', // 获取仪器列表
|
||||
update: '/api/i4/v1/update/machine.php', // 更新仪器信息
|
||||
create: '/api/i4/v1/create/machine.php', // 创建仪器
|
||||
export: '/download.php?x=Machine List' // 导出仪器列表
|
||||
},
|
||||
|
||||
// 用户相关接口
|
||||
user: {
|
||||
list: '/api/i4/v1/get/users.php', // 获取用户列表
|
||||
listWithDisabled: '/api/i4/v1/get/users.php?include_disabled=1' // 包含禁用用户
|
||||
},
|
||||
|
||||
// 品牌相关接口
|
||||
brand: {
|
||||
list: '/api/i4/v1/get/brands.php' // 获取品牌列表
|
||||
},
|
||||
|
||||
// 客户相关接口
|
||||
customer: {
|
||||
list: '/api/i4/v1/get/customers.php' // 获取客户列表
|
||||
},
|
||||
|
||||
// 来源类型相关接口
|
||||
sourceType: {
|
||||
list: '/api/i4/v1/get/eq_sources.php' // 获取来源类型列表
|
||||
},
|
||||
|
||||
// 其他来源类型相关接口
|
||||
otherSource: {
|
||||
list: '/api/i4/v1/get/eq_source_other.php' // 获取其他来源类型列表
|
||||
},
|
||||
|
||||
// 状态相关接口
|
||||
status: {
|
||||
list: '/api/i4/v1/get/eq_status.php' // 获取状态列表
|
||||
},
|
||||
|
||||
// 位置相关接口
|
||||
location: {
|
||||
list: '/api/i4/v1/get/locations.php' // 获取位置列表
|
||||
},
|
||||
|
||||
// 部门相关接口
|
||||
department: {
|
||||
list: '/api/i4/v1/get/departments.php' // 获取部门列表
|
||||
}
|
||||
};
|
||||
|
||||
export default ApiPaths;
|
||||
@@ -2,17 +2,37 @@ import axios from "axios";
|
||||
import crypto from "@/utils/crypto";
|
||||
import Cookies from 'js-cookie';
|
||||
import { ElMessage, ElLoading } from 'element-plus';
|
||||
import router, { clearLoginState } from '@/router'; // 修正路由导入路径
|
||||
|
||||
// 获取基础 API 地址
|
||||
const API_BASE_URL = import.meta.env.VITE_APP_API_BASE_URL;
|
||||
|
||||
if (!API_BASE_URL) {
|
||||
// 获取内网 API 地址
|
||||
const VITE_APP_API_INT_BASE_URL = import.meta.env.VITE_APP_API_INT_BASE_URL;
|
||||
|
||||
// 获取外网 API 地址
|
||||
const VITE_APP_API_EXT_BASE_URL = import.meta.env.VITE_APP_API_EXT_BASE_URL;
|
||||
|
||||
|
||||
if (!API_BASE_URL || !VITE_APP_API_INT_BASE_URL || !VITE_APP_API_EXT_BASE_URL) {
|
||||
console.warn('API基础地址未配置,请在环境变量中设置VITE_APP_API_BASE_URL');
|
||||
}
|
||||
|
||||
// 设置基础 API 地址
|
||||
const hostname = `${window.location.hostname}`;
|
||||
var auth_api_url = ''
|
||||
if(hostname == '192.168.6.104') {
|
||||
auth_api_url = API_BASE_URL
|
||||
}else if (hostname == '8.134.12.216') {
|
||||
auth_api_url = VITE_APP_API_EXT_BASE_URL
|
||||
}else if (hostname == '10.97.245.96') {
|
||||
auth_api_url = VITE_APP_API_INT_BASE_URL
|
||||
}
|
||||
|
||||
|
||||
// 创建axios实例
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
baseURL: auth_api_url,
|
||||
timeout: 15000, // 请求超时时间
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -59,16 +79,16 @@ api.interceptors.request.use(
|
||||
config.headers['Authorization'] = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
// 处理POST请求加密
|
||||
// 处理POST请求加密(不加密请求会标记isEncrypted: true)
|
||||
if (config.method?.toLowerCase() === 'post' && !config.isEncrypted) {
|
||||
try {
|
||||
// 添加用户信息
|
||||
let requestData = { ...config.data };
|
||||
const storedUserInfo = localStorage.getItem('info');
|
||||
const storedUserInfo = localStorage.getItem('userInfo'); // 修正存储键名
|
||||
|
||||
if (storedUserInfo) {
|
||||
const info = JSON.parse(crypto.decrypt(storedUserInfo));
|
||||
requestData['user_id'] = info.id;
|
||||
const userInfo = JSON.parse(crypto.decrypt(storedUserInfo));
|
||||
requestData['user_id'] = userInfo.userId; // 匹配后端返回的userId字段
|
||||
}
|
||||
|
||||
// 加密数据
|
||||
@@ -99,9 +119,8 @@ api.interceptors.response.use(
|
||||
|
||||
// 处理业务逻辑错误
|
||||
const { data } = response;
|
||||
|
||||
// 根据实际后端规范调整
|
||||
if (data.code && data.code !== 200) {
|
||||
// 根据实际后端规范调整(确保与后端code一致)
|
||||
if (data.code == null || ![0, 200].includes(data.code)) {
|
||||
ElMessage.error(data.message || '操作失败');
|
||||
return Promise.reject(new Error(data.message || '请求失败'));
|
||||
}
|
||||
@@ -123,13 +142,10 @@ api.interceptors.response.use(
|
||||
switch (status) {
|
||||
case 401:
|
||||
// 未授权,处理token过期逻辑
|
||||
if (!router.currentRoute.value.path.includes('/auth')) {
|
||||
ElMessage.error('登录已过期,请重新登录');
|
||||
// 清除登录状态
|
||||
Cookies.remove('access_token');
|
||||
localStorage.removeItem('info');
|
||||
// 跳转到登录页(假设使用vue-router)
|
||||
if (window.router) {
|
||||
window.router.push('/');
|
||||
clearLoginState(); // 使用路由文件中的清除方法
|
||||
router.push('/auth');
|
||||
}
|
||||
break;
|
||||
case 403:
|
||||
@@ -216,7 +232,7 @@ const request = {
|
||||
},
|
||||
|
||||
/**
|
||||
* 不加密的POST请求
|
||||
* 不加密的POST请求(用于登录等不需要加密的场景)
|
||||
* @param {string} url 请求地址
|
||||
* @param {object} data 请求数据
|
||||
* @param {object} config 额外配置
|
||||
@@ -228,6 +244,7 @@ const request = {
|
||||
method: 'post',
|
||||
data,
|
||||
isEncrypted: true, // 标记为已加密,跳过加密处理
|
||||
showLoading: config.showLoading !== false, // 默认为显示加载状态
|
||||
...config
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,30 +1,103 @@
|
||||
import CryptoJS from 'crypto-js';
|
||||
import CryptoJS from 'crypto-js'; // 假设使用crypto-js库
|
||||
|
||||
const key = CryptoJS.enc.Utf8.parse(import.meta.env.VITE_APP_KEY);
|
||||
const iv = CryptoJS.enc.Utf8.parse(import.meta.env.VITE_APP_IV);
|
||||
// 密钥必须与加密时完全一致(建议从环境变量读取,避免硬编码)
|
||||
const SECRET_KEY = import.meta.env.VITE_APP_KEY || 'your-secret-key-32bytes'; // 32字节密钥(AES-256)
|
||||
const IV = import.meta.env.VITE_APP_IV || '16bytesIvValue!'; // 16字节IV
|
||||
|
||||
const crypto = {
|
||||
encrypt: (str) => {
|
||||
const encrypted = CryptoJS.AES.encrypt(str, key, {
|
||||
// 验证密钥和IV长度(关键修复)
|
||||
if (SECRET_KEY.length !== 32) {
|
||||
console.error(`加密密钥长度错误:需要32字节,实际${SECRET_KEY.length}字节`);
|
||||
}
|
||||
if (IV.length !== 16) {
|
||||
console.error(`IV长度错误:需要16字节,实际${IV.length}字节`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密函数
|
||||
* @param {string} data - 要加密的字符串(需确保是有效的UTF-8)
|
||||
* @returns {string} 加密后的Base64字符串
|
||||
*/
|
||||
export const encrypt = (data) => {
|
||||
try {
|
||||
// 确保输入是字符串,且是有效的UTF-8
|
||||
if (typeof data !== 'string') {
|
||||
throw new Error('加密数据必须是字符串');
|
||||
}
|
||||
|
||||
// 验证UTF-8有效性
|
||||
new TextDecoder().decode(new TextEncoder().encode(data));
|
||||
|
||||
const key = CryptoJS.enc.Utf8.parse(SECRET_KEY);
|
||||
const iv = CryptoJS.enc.Utf8.parse(IV);
|
||||
|
||||
const encrypted = CryptoJS.AES.encrypt(
|
||||
data,
|
||||
key,
|
||||
{
|
||||
iv: iv,
|
||||
mode: CryptoJS.mode.CBC,
|
||||
padding: CryptoJS.pad.Pkcs7
|
||||
});
|
||||
// 将加密结果转换为字节数组,然后转换为十六进制字符串
|
||||
const hexString = CryptoJS.enc.Hex.stringify(encrypted.ciphertext);
|
||||
return hexString;
|
||||
},
|
||||
decrypt: (hexString) => {
|
||||
// 将十六进制字符串转换回字节数组
|
||||
const ciphertext = CryptoJS.enc.Hex.parse(hexString);
|
||||
const encryptedParams = CryptoJS.lib.CipherParams.create({ ciphertext });
|
||||
const decrypted = CryptoJS.AES.decrypt(encryptedParams, key, {
|
||||
iv: iv,
|
||||
mode: CryptoJS.mode.CBC,
|
||||
padding: CryptoJS.pad.Pkcs7
|
||||
});
|
||||
return decrypted.toString(CryptoJS.enc.Utf8);
|
||||
}
|
||||
);
|
||||
|
||||
// 返回Base64格式的密文(避免特殊字符问题)
|
||||
return encrypted.ciphertext.toString(CryptoJS.enc.Base64);
|
||||
} catch (error) {
|
||||
console.error('加密失败:', error);
|
||||
throw new Error(`加密错误: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
export default crypto;
|
||||
/**
|
||||
* 解密函数
|
||||
* @param {string} encryptedData - 加密后的Base64字符串
|
||||
* @returns {string} 解密后的UTF-8字符串
|
||||
*/
|
||||
export const decrypt = (encryptedData) => {
|
||||
try {
|
||||
// 验证输入是否为字符串
|
||||
if (typeof encryptedData !== 'string' || encryptedData.trim() === '') {
|
||||
throw new Error('解密数据必须是非空字符串');
|
||||
}
|
||||
|
||||
// 验证Base64有效性(关键修复)
|
||||
if (!/^[A-Za-z0-9+/=]+$/.test(encryptedData)) {
|
||||
throw new Error('解密数据不是有效的Base64格式');
|
||||
}
|
||||
|
||||
const key = CryptoJS.enc.Utf8.parse(SECRET_KEY);
|
||||
const iv = CryptoJS.enc.Utf8.parse(IV);
|
||||
|
||||
// 先将Base64转换为CipherParams对象
|
||||
const cipherParams = CryptoJS.lib.CipherParams.create({
|
||||
ciphertext: CryptoJS.enc.Base64.parse(encryptedData)
|
||||
});
|
||||
|
||||
const decrypted = CryptoJS.AES.decrypt(
|
||||
cipherParams,
|
||||
key,
|
||||
{
|
||||
iv: iv,
|
||||
mode: CryptoJS.mode.CBC,
|
||||
padding: CryptoJS.pad.Pkcs7
|
||||
}
|
||||
);
|
||||
|
||||
// 转换为UTF-8字符串(处理可能的编码错误)
|
||||
const result = decrypted.toString(CryptoJS.enc.Utf8);
|
||||
|
||||
if (!result) {
|
||||
throw new Error('解密结果为空,可能是密钥不匹配或数据损坏');
|
||||
}
|
||||
|
||||
// 再次验证UTF-8有效性
|
||||
new TextDecoder().decode(new TextEncoder().encode(result));
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('解密失败:', error);
|
||||
throw new Error(`解密错误: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
export default { encrypt, decrypt };
|
||||
|
||||
6
src/views/dashboard/test.vue
Normal file
6
src/views/dashboard/test.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
test
|
||||
</template>
|
||||
<script>
|
||||
|
||||
</script>
|
||||
1000
src/views/equipment/Testers.vue
Normal file
1000
src/views/equipment/Testers.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,43 @@
|
||||
<template>
|
||||
<div class="main-container">
|
||||
<!-- 左侧菜单栏组件 -->
|
||||
<div class="menu-view">
|
||||
<div class="main-container" :class="themeClass">
|
||||
<!-- 左侧菜单栏组件 - 添加主题类传递 -->
|
||||
<div class="menu-view" :class="{ collapsed: isSidebarCollapsed }">
|
||||
<MainMenu
|
||||
:menu-items="menuItems"
|
||||
:collapsed="isSidebarCollapsed"
|
||||
@toggle="isSidebarCollapsed = !isSidebarCollapsed"
|
||||
@menu-click="handleMenuChange"
|
||||
:theme-class="themeClass"
|
||||
/>
|
||||
</div>
|
||||
<!-- 右侧主内容区 -->
|
||||
<div class="right-view">
|
||||
<div class="top-view">
|
||||
<!-- 主题和语言切换控件 -->
|
||||
<div class="header-controls">
|
||||
<!-- 语言切换 -->
|
||||
<el-select
|
||||
v-model="currentLanguage"
|
||||
size="small"
|
||||
class="language-select"
|
||||
@change="handleLanguageChange"
|
||||
>
|
||||
<el-option label="中文" value="zh-CN"></el-option>
|
||||
<el-option label="English" value="en-US"></el-option>
|
||||
</el-select>
|
||||
|
||||
<!-- 主题切换 -->
|
||||
<el-select
|
||||
v-model="currentTheme"
|
||||
size="small"
|
||||
class="theme-select"
|
||||
@change="handleThemeChange"
|
||||
>
|
||||
<el-option :label="t('theme.light')" value="light"></el-option>
|
||||
<el-option :label="t('theme.dark')" value="dark"></el-option>
|
||||
<el-option :label="t('theme.custom')" value="custom"></el-option>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 顶部用户信息栏组件 -->
|
||||
<UserInfoBar
|
||||
:user-info="currentUser"
|
||||
@@ -22,138 +48,197 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="connect-view">
|
||||
<!-- 内容显示区 -->
|
||||
<ContentArea>
|
||||
<template #header>
|
||||
<ContentHeader
|
||||
:title="currentPageTitle"
|
||||
:actions="currentPageActions"
|
||||
@action-click="handlePageAction"
|
||||
/>
|
||||
</template>
|
||||
<div>
|
||||
<!-- 路由视图放在右侧内容区 -->
|
||||
<RouterView />
|
||||
</div>
|
||||
</ContentArea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// 脚本内容保持不变
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { ref, onMounted, inject, watch, nextTick } from 'vue'; // 增加nextTick用于强制刷新
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ElMessage, ElNotification } from 'element-plus';
|
||||
import Cookies from 'js-cookie';
|
||||
import crypto from '@/utils/crypto';
|
||||
import MainMenu from './layout/MainMenu.vue';
|
||||
import UserInfoBar from './layout/UserInfoBar.vue';
|
||||
import ContentArea from './layout/ContentArea.vue';
|
||||
import ContentHeader from './layout/ContentHeader.vue';
|
||||
import { getLocale, setLocale } from '@/utils/storage';
|
||||
|
||||
// 模拟API函数(实际项目中应从api文件导入)
|
||||
const getUserInfo = () => Promise.resolve({
|
||||
name: '管理员',
|
||||
role: '系统管理员',
|
||||
avatar: 'https://picsum.photos/id/64/200/200'
|
||||
// 注入主题配置
|
||||
const theme = inject('theme', {
|
||||
currentTheme: 'light',
|
||||
switchTheme: () => {}
|
||||
});
|
||||
|
||||
const logout = () => Promise.resolve();
|
||||
// 国际化
|
||||
const { locale, t } = useI18n();
|
||||
|
||||
// 退出登录函数
|
||||
const logout = async () => {
|
||||
try {
|
||||
localStorage.removeItem('userInfo');
|
||||
localStorage.removeItem('isLoggedIn');
|
||||
localStorage.removeItem('user_gid');
|
||||
Cookies.remove('access_token');
|
||||
|
||||
await router.push('/auth');
|
||||
window.location.reload();
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
console.error('退出登录失败:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
// 状态管理
|
||||
const isSidebarCollapsed = ref(false);
|
||||
const unreadNotifications = ref(3);
|
||||
const currentPageTitle = ref('仪表盘');
|
||||
const currentUser = ref({});
|
||||
const currentUser = ref({
|
||||
name: t('user.unknown'),
|
||||
role: t('user.unauthorized'),
|
||||
avatar: 'https://picsum.photos/id/64/200/200',
|
||||
userId: '',
|
||||
departmentId: ''
|
||||
});
|
||||
|
||||
// 主题和语言状态
|
||||
const currentTheme = ref(theme.currentTheme);
|
||||
const currentLanguage = ref(getLocale() || 'zh-CN');
|
||||
const themeClass = ref(`theme-${currentTheme.value}`);
|
||||
|
||||
// 路由实例
|
||||
const router = useRouter();
|
||||
|
||||
// 菜单配置
|
||||
const menuItems = reactive([
|
||||
{
|
||||
label: '仪表盘',
|
||||
icon: 'fa-tachometer',
|
||||
path: '/main/dashboard',
|
||||
active: true
|
||||
},
|
||||
{
|
||||
label: '用户管理',
|
||||
icon: 'fa-users',
|
||||
path: '/main/users',
|
||||
active: false,
|
||||
children: [
|
||||
{ label: '用户列表', icon: 'fa-list', path: '/main/users/list', active: false },
|
||||
{ label: '新增用户', icon: 'fa-plus', path: '/main/users/add', active: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '系统设置',
|
||||
icon: 'fa-cog',
|
||||
path: '/main/settings',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
label: '数据报表',
|
||||
icon: 'fa-bar-chart',
|
||||
path: '/main/reports',
|
||||
active: false
|
||||
}
|
||||
]);
|
||||
// 从localStorage获取并解密用户信息
|
||||
const loadUserInfo = () => {
|
||||
try {
|
||||
const encryptedUserInfo = localStorage.getItem('userInfo');
|
||||
|
||||
// 当前页面操作按钮
|
||||
const currentPageActions = ref([
|
||||
{ label: '新增', icon: 'fa-plus', action: 'add' },
|
||||
{ label: '导出', icon: 'fa-download', action: 'export' },
|
||||
{ label: '刷新', icon: 'fa-refresh', action: 'refresh' }
|
||||
]);
|
||||
if (!encryptedUserInfo) {
|
||||
throw new Error(t('error.noUserInfo'));
|
||||
}
|
||||
|
||||
if (!/^[A-Za-z0-9+/=]+$/.test(encryptedUserInfo)) {
|
||||
throw new Error(t('error.invalidBase64'));
|
||||
}
|
||||
|
||||
const decryptedInfo = crypto.decrypt(encryptedUserInfo);
|
||||
|
||||
if (!decryptedInfo || decryptedInfo === '') {
|
||||
throw new Error(t('error.decryptFailed'));
|
||||
}
|
||||
|
||||
let userData;
|
||||
try {
|
||||
userData = JSON.parse(decryptedInfo);
|
||||
} catch (jsonError) {
|
||||
throw new Error(`${t('error.jsonParseFailed')}: ${jsonError.message}`);
|
||||
}
|
||||
|
||||
if (typeof userData !== 'object' || userData === null) {
|
||||
throw new Error(t('error.invalidUserInfo'));
|
||||
}
|
||||
|
||||
const userName = userData.userName || userData.loginName || t('user.unknown');
|
||||
const roles = Array.isArray(userData.roles) ? userData.roles : [];
|
||||
const role = roles.length > 0 ? roles[0] : t('user.defaultRole');
|
||||
|
||||
if (userData.userGid) {
|
||||
localStorage.setItem('user_gid', userData.userGid);
|
||||
} else if (userData.groupId) {
|
||||
localStorage.setItem('user_gid', userData.groupId);
|
||||
}
|
||||
|
||||
return {
|
||||
name: userName,
|
||||
role: role,
|
||||
avatar: 'https://picsum.photos/id/64/200/200',
|
||||
userId: userData.userId || '',
|
||||
departmentId: userData.departmentId || '',
|
||||
loginName: userData.loginName || '',
|
||||
loginTime: userData.loginTime || ''
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('加载用户信息失败详情:', error);
|
||||
localStorage.removeItem('userInfo');
|
||||
localStorage.removeItem('isLoggedIn');
|
||||
localStorage.removeItem('user_gid');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const userData = await getUserInfo();
|
||||
locale.value = currentLanguage.value;
|
||||
|
||||
const isLoggedIn = localStorage.getItem('isLoggedIn') === 'true';
|
||||
const hasToken = !!Cookies.get('access_token');
|
||||
|
||||
if (!isLoggedIn || !hasToken) {
|
||||
ElMessage.warning(t('auth.sessionExpired'));
|
||||
await router.push('/auth');
|
||||
return;
|
||||
}
|
||||
|
||||
const userData = loadUserInfo();
|
||||
currentUser.value = userData;
|
||||
setActiveMenuByRoute(router.currentRoute.value.path);
|
||||
|
||||
ElNotification.success({
|
||||
title: t('auth.loginSuccess'),
|
||||
message: `${t('auth.welcomeBack')},${userData.name}`,
|
||||
duration: 3000
|
||||
});
|
||||
} catch (error) {
|
||||
ElMessage.error('加载用户信息失败');
|
||||
console.error(error);
|
||||
ElMessage.error(`${t('error.loadUserFailed')}: ${error.message}`);
|
||||
console.error('初始化用户信息时出错:', error);
|
||||
|
||||
setTimeout(() => {
|
||||
router.push('/auth');
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
|
||||
// 根据路由设置活跃菜单
|
||||
const setActiveMenuByRoute = (path) => {
|
||||
menuItems.forEach(item => {
|
||||
item.active = item.path === path;
|
||||
// 处理主题切换 - 增加强制刷新逻辑
|
||||
const handleThemeChange = async (newTheme) => {
|
||||
if (theme.switchTheme) {
|
||||
theme.switchTheme(newTheme);
|
||||
currentTheme.value = newTheme;
|
||||
themeClass.value = `theme-${newTheme}`;
|
||||
|
||||
if (item.children && item.children.length) {
|
||||
item.children.forEach(subItem => {
|
||||
subItem.active = subItem.path === path;
|
||||
});
|
||||
item.expanded = item.children.some(sub => sub.active);
|
||||
// 强制DOM更新,确保所有子组件感知主题变化
|
||||
await nextTick();
|
||||
// 触发重绘技巧:短暂移除再添加主题类
|
||||
const tempClass = themeClass.value;
|
||||
themeClass.value = '';
|
||||
await nextTick();
|
||||
themeClass.value = tempClass;
|
||||
|
||||
ElMessage.success(t('theme.themeSwitched', { theme: newTheme }));
|
||||
}
|
||||
};
|
||||
|
||||
// 处理语言切换
|
||||
const handleLanguageChange = (newLocale) => {
|
||||
locale.value = newLocale;
|
||||
setLocale(newLocale);
|
||||
currentLanguage.value = newLocale;
|
||||
ElMessage.success(t('common.languageChanged'));
|
||||
|
||||
if (!currentUser.value.name || currentUser.value.name === '未知用户') {
|
||||
currentUser.value.name = t('user.unknown');
|
||||
}
|
||||
if (!currentUser.value.role || currentUser.value.role === '未授权') {
|
||||
currentUser.value.role = t('user.unauthorized');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 处理菜单切换
|
||||
const handleMenuChange = (path) => {
|
||||
currentPageTitle.value = menuItems.find(item => item.path === path)?.label || '页面';
|
||||
if (path && path !== router.currentRoute.value.path) {
|
||||
router.push(path);
|
||||
setActiveMenuByRoute(path);
|
||||
};
|
||||
|
||||
// 处理页面操作
|
||||
const handlePageAction = (action) => {
|
||||
switch (action) {
|
||||
case 'add':
|
||||
ElMessage.info('新增操作');
|
||||
break;
|
||||
case 'export':
|
||||
ElMessage.info('导出操作');
|
||||
break;
|
||||
case 'refresh':
|
||||
ElMessage.success('已刷新');
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -161,68 +246,130 @@ const handlePageAction = (action) => {
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
router.push('/login');
|
||||
ElMessage.success('退出登录成功');
|
||||
ElMessage.success(t('auth.logoutSuccess'));
|
||||
} catch (error) {
|
||||
ElMessage.error('退出登录失败');
|
||||
ElMessage.error(t('auth.logoutFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// 处理通知点击
|
||||
const handleNotificationClick = () => {
|
||||
router.push('/main/notifications');
|
||||
unreadNotifications.value = 0;
|
||||
ElMessage.info(t('notification.openCenter'));
|
||||
};
|
||||
|
||||
// 处理设置点击
|
||||
const handleSettingsClick = () => {
|
||||
router.push('/main/settings');
|
||||
router.push('/profile');
|
||||
};
|
||||
|
||||
// 监听主题变化 - 确保菜单同步更新
|
||||
watch(
|
||||
() => theme.currentTheme,
|
||||
async (newTheme) => {
|
||||
currentTheme.value = newTheme;
|
||||
themeClass.value = `theme-${newTheme}`;
|
||||
// 主题变化时强制菜单重绘
|
||||
await nextTick();
|
||||
const menuEl = document.querySelector('.menu-view');
|
||||
if (menuEl) {
|
||||
menuEl.style.display = 'none';
|
||||
await nextTick();
|
||||
menuEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 关键修复:设置html和body的高度基准 */
|
||||
:root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
width: 100%;
|
||||
height: 100vh; /* 使用视口高度确保占满屏幕 */
|
||||
display: flex; /* 使用flex布局避免定位问题 */
|
||||
overflow: hidden; /* 防止页面溢出 */
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-text);
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
/* 关键修复:确保主题类能影响菜单内部样式 */
|
||||
.menu-view {
|
||||
/* 移除absolute定位,使用flex布局 */
|
||||
width: 200px;
|
||||
height: 100%;
|
||||
border: 1px solid #ff0000;
|
||||
flex-shrink: 0; /* 防止菜单被压缩 */
|
||||
border-right: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
transition: width 0.3s ease;
|
||||
background-color: var(--color-surface);
|
||||
}
|
||||
|
||||
/* 穿透到子组件的菜单样式 - 解决scoped隔离问题 */
|
||||
:deep(.menu-view) {
|
||||
--menu-bg-color: var(--color-surface);
|
||||
--menu-text-color: var(--color-text);
|
||||
--menu-hover-color: var(--color-surface-alt);
|
||||
--menu-active-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.menu-view.collapsed {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.right-view {
|
||||
flex: 1; /* 自动填充剩余空间 */
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
border: 1px solid #00ff00;
|
||||
display: flex;
|
||||
flex-direction: column; /* 垂直排列顶部和内容区 */
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.top-view {
|
||||
height: 60px; /* 固定顶部高度 */
|
||||
border-bottom: 1px solid #ccc;
|
||||
height: 60px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--color-surface);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.language-select, .theme-select {
|
||||
width: 110px;
|
||||
/* 确保选择器本身也响应主题 */
|
||||
:deep(.el-input__inner) {
|
||||
background-color: var(--color-surface-alt) !important;
|
||||
border-color: var(--color-border) !important;
|
||||
color: var(--color-text) !important;
|
||||
}
|
||||
:deep(.el-select-dropdown) {
|
||||
background-color: var(--color-surface) !important;
|
||||
border-color: var(--color-border) !important;
|
||||
:deep(.el-select-dropdown__item) {
|
||||
color: var(--color-text) !important;
|
||||
&:hover {
|
||||
background-color: var(--color-surface-alt) !important;
|
||||
}
|
||||
&.selected {
|
||||
color: var(--color-primary) !important;
|
||||
background-color: var(--color-surface-alt) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.connect-view {
|
||||
flex: 1; /* 填充剩余高度 */
|
||||
overflow: auto; /* 内容溢出时可滚动 */
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background-color: var(--color-background);
|
||||
border: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
<template>
|
||||
<div class="content-header bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex flex-wrap items-center justify-between">
|
||||
<h1 class="page-title text-xl font-semibold">{{ title }}</h1>
|
||||
|
||||
<div class="page-actions flex items-center space-x-2 mt-2 sm:mt-0">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
import { ElButton } from 'element-plus';
|
||||
|
||||
// 定义属性
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
actions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['action-click']);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-header {
|
||||
@apply shadow-sm;
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
@apply flex-wrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,188 +1,85 @@
|
||||
<template>
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
<div class="main-menu-container" :class="themeClass">
|
||||
<!-- 侧边栏菜单 -->
|
||||
<Sidebar
|
||||
:is-collapse="sidebarCollapse"
|
||||
@toggle-collapse="handleSidebarToggle"
|
||||
class="sidebar-menu"
|
||||
mode="vertical"
|
||||
@select="handleMenuSelect"
|
||||
background-color=""
|
||||
text-color="#94a3b8"
|
||||
active-text-color="#ffffff"
|
||||
:collapse="isCollapse"
|
||||
:collapse-transition="false"
|
||||
>
|
||||
<!-- 加载状态 -->
|
||||
<template v-if="loading">
|
||||
<el-menu-item v-for="i in 3" :key="i">
|
||||
<el-skeleton active :width="isCollapse ? '24px' : '100%'" />
|
||||
</el-menu-item>
|
||||
</template>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<template v-else-if="error">
|
||||
<el-menu-item disabled>
|
||||
<el-alert
|
||||
title="菜单加载失败"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="error-alert"
|
||||
/>
|
||||
<el-button
|
||||
size="mini"
|
||||
type="text"
|
||||
@click="fetchMenuData"
|
||||
class="retry-btn"
|
||||
>
|
||||
重试
|
||||
</el-button>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
|
||||
<!-- 菜单内容 -->
|
||||
<template v-else>
|
||||
<menu-item
|
||||
v-for="item in menuData"
|
||||
:key="item.id"
|
||||
:menu-item="item"
|
||||
:is-collapse="isCollapse"
|
||||
/>
|
||||
</template>
|
||||
</el-menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import MenuItem from './MenuItem.vue';
|
||||
import { request } from '@/utils/api'; // 假设request工具已存在
|
||||
import { Menu } from '@element-plus/icons-vue';
|
||||
import { ref, inject, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
// 导入Sidebar组件
|
||||
import Sidebar from '/src/components/menu/Sidebar.vue'
|
||||
|
||||
// 组件属性
|
||||
const props = defineProps({
|
||||
isCollapse: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
// 注入主题配置
|
||||
const theme = inject('theme', {
|
||||
currentTheme: 'light',
|
||||
switchTheme: () => {}
|
||||
})
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 控制侧边栏折叠状态
|
||||
const sidebarCollapse = ref(false)
|
||||
|
||||
// 计算主题类名
|
||||
const themeClass = computed(() => `theme-${theme.currentTheme}`)
|
||||
|
||||
// 处理侧边栏折叠/展开切换
|
||||
const handleSidebarToggle = () => {
|
||||
sidebarCollapse.value = !sidebarCollapse.value
|
||||
}
|
||||
});
|
||||
|
||||
// 状态管理
|
||||
const loading = ref(true);
|
||||
const error = ref(false);
|
||||
const rawMenuData = ref([]);
|
||||
const menuData = ref([]);
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
// 当前激活的菜单
|
||||
const activeMenu = computed(() => {
|
||||
return route.path;
|
||||
});
|
||||
|
||||
// 监听路由变化,更新激活菜单
|
||||
watch(
|
||||
() => route.path,
|
||||
(newPath) => {
|
||||
// 可以在这里添加额外的逻辑
|
||||
}
|
||||
);
|
||||
|
||||
// 处理菜单选择
|
||||
const handleMenuSelect = (key) => {
|
||||
// 如果是外部链接
|
||||
if (key.startsWith('http://') || key.startsWith('https://')) {
|
||||
window.open(key, '_blank');
|
||||
return;
|
||||
}
|
||||
|
||||
// 内部路由跳转
|
||||
if (key && key !== route.path) {
|
||||
router.push(key);
|
||||
}
|
||||
};
|
||||
|
||||
// 将扁平数据转换为树形结构
|
||||
const buildMenuTree = (items) => {
|
||||
const map = new Map();
|
||||
const tree = [];
|
||||
|
||||
// 首先将所有项存入map
|
||||
items.forEach(item => {
|
||||
map.set(item.id, { ...item, children: [] });
|
||||
});
|
||||
|
||||
// 构建树形结构
|
||||
items.forEach(item => {
|
||||
const current = map.get(item.id);
|
||||
if (!current) return;
|
||||
|
||||
if (item.parent_id === null || !map.has(item.parent_id)) {
|
||||
// 顶级菜单
|
||||
tree.push(current);
|
||||
} else {
|
||||
// 子菜单
|
||||
const parent = map.get(item.parent_id);
|
||||
if (parent) {
|
||||
parent.children.push(current);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 按menu_code排序,确保菜单顺序正确
|
||||
return tree.sort((a, b) => a.menu_code.localeCompare(b.menu_code));
|
||||
};
|
||||
|
||||
// 从API获取菜单数据
|
||||
const fetchMenuData = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
error.value = false;
|
||||
|
||||
const response = await request.postWithoutEncryption(
|
||||
'/api/i4/v1/get/menu.php',
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
timeout: 10000
|
||||
}
|
||||
);
|
||||
|
||||
// 假设后端返回的数据结构是 { code: 200, data: MenuItem[] }
|
||||
if (response.code === 200 && Array.isArray(response.data)) {
|
||||
rawMenuData.value = response.data;
|
||||
menuData.value = buildMenuTree(rawMenuData.value);
|
||||
} else {
|
||||
throw new Error('菜单数据格式不正确');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取菜单数据失败:', err);
|
||||
error.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 组件挂载时获取菜单数据
|
||||
onMounted(() => {
|
||||
fetchMenuData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.main-menu-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-background);
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-text);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
height: 100%;
|
||||
border-right: none;
|
||||
width: 100%;
|
||||
background-color: var(--color-surface);
|
||||
border-right: 1px solid var(--color-border);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.error-alert {
|
||||
margin: 10px;
|
||||
/* 适配不同主题的滚动条样式 */
|
||||
:deep(.sidebar-menu ::-webkit-scrollbar) {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
margin-left: 10px;
|
||||
color: #409eff;
|
||||
:deep(.sidebar-menu ::-webkit-scrollbar-track) {
|
||||
background: var(--color-surface-alt);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
:deep(.sidebar-menu ::-webkit-scrollbar-thumb) {
|
||||
background: var(--color-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
:deep(.sidebar-menu ::-webkit-scrollbar-thumb:hover) {
|
||||
background: var(--color-primary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
<template>
|
||||
<div v-if="!hasChildren">
|
||||
<el-menu-item
|
||||
:index="menuItem.menu_url"
|
||||
:disabled="menuItem.disabled"
|
||||
:class="{ 'menu-item': true }"
|
||||
>
|
||||
<el-icon v-if="menuItem.menu_grade === 1 && !isCollapse">
|
||||
<Menu />
|
||||
</el-icon>
|
||||
<span>{{ menuItem.menu_name }}</span>
|
||||
</el-menu-item>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<el-sub-menu
|
||||
:index="menuItem.menu_url"
|
||||
:disabled="menuItem.disabled"
|
||||
>
|
||||
<template #title>
|
||||
<el-icon v-if="menuItem.menu_grade === 1 && !isCollapse">
|
||||
<Menu />
|
||||
</el-icon>
|
||||
<span>{{ menuItem.menu_name }}</span>
|
||||
</template>
|
||||
|
||||
<menu-item
|
||||
v-for="child in menuItem.children"
|
||||
:key="child.id"
|
||||
:menu-item="child"
|
||||
:is-collapse="isCollapse"
|
||||
/>
|
||||
</el-sub-menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, computed } from 'vue';
|
||||
import { Menu } from '@element-plus/icons-vue';
|
||||
|
||||
// 接收父组件传递的属性
|
||||
const props = defineProps({
|
||||
menuItem: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
isCollapse: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
// 判断是否有子菜单
|
||||
const hasChildren = computed(() => {
|
||||
return props.menuItem.children && props.menuItem.children.length > 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.menu-item {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,58 +1,82 @@
|
||||
<template>
|
||||
<header class="user-info-bar bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 h-16 flex items-center justify-between px-6 shadow-sm">
|
||||
<!-- 用户信息 -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="relative">
|
||||
<img
|
||||
:src="userInfo.avatar || defaultAvatar"
|
||||
alt="用户头像"
|
||||
class="w-10 h-10 rounded-full object-cover border-2 border-white dark:border-gray-700 shadow-sm"
|
||||
>
|
||||
<span class="absolute bottom-0 right-0 w-3 h-3 bg-green-500 rounded-full border-2 border-white dark:border-gray-800"></span>
|
||||
</div>
|
||||
|
||||
<div class="hidden md:block">
|
||||
<div class="font-medium">{{ userInfo.name || '用户' }}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">{{ userInfo.role || '普通用户' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex items-center space-x-3">
|
||||
<header class="user-info-bar" :class="themeClass">
|
||||
<!-- 通知按钮 -->
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
class="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors relative"
|
||||
class="action-btn notification-btn"
|
||||
@click="$emit('notification-click')"
|
||||
aria-label="通知"
|
||||
:title="t('user.notifications')"
|
||||
>
|
||||
<i class="fa fa-bell-o text-lg"></i>
|
||||
<span v-if="unreadCount > 0" class="absolute top-1 right-1 w-4 h-4 bg-red-500 text-white text-xs rounded-full flex items-center justify-center">
|
||||
<BellIcon class="icon" />
|
||||
<span v-if="unreadCount > 0" class="notification-badge">
|
||||
{{ unreadCount > 9 ? '9+' : unreadCount }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
@click="$emit('settings-click')"
|
||||
aria-label="设置"
|
||||
<!-- 用户信息 - 右侧布局 -->
|
||||
<div class="user-info">
|
||||
<!-- 头像与状态 -->
|
||||
<div class="avatar-container" @click.stop="toggleMenu">
|
||||
<img
|
||||
:src="userInfo.avatar || defaultAvatar"
|
||||
:alt="t('user.profile')"
|
||||
class="avatar-img"
|
||||
>
|
||||
<i class="fa fa-cog text-lg"></i>
|
||||
</button>
|
||||
<span class="status-indicator" :title="t('user.online')"></span>
|
||||
|
||||
<button
|
||||
class="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
@click="$emit('logout')"
|
||||
aria-label="退出登录"
|
||||
<!-- 弹出菜单 -->
|
||||
<el-menu
|
||||
v-if="menuVisible"
|
||||
class="user-dropdown-menu"
|
||||
mode="vertical"
|
||||
@select="handleMenuSelect"
|
||||
>
|
||||
<i class="fa fa-sign-out text-lg"></i>
|
||||
</button>
|
||||
<el-menu-item index="profile">
|
||||
<UserIcon class="icon mr-2" />{{ t('user.profile') }}
|
||||
</el-menu-item>
|
||||
<el-menu-item index="settings">
|
||||
<SettingIcon class="icon mr-2" />{{ t('user.settings') }}
|
||||
</el-menu-item>
|
||||
<el-menu-item index="logout" class="logout-item">
|
||||
<ArrowRightIcon class="icon mr-2" />{{ t('user.logout') }}
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</div>
|
||||
|
||||
<!-- 基本信息 -->
|
||||
<div class="user-text">
|
||||
<div class="user-name-text">{{ userInfo.name || t('user.defaultName') }}</div>
|
||||
<div class="user-role-text">{{ userInfo.role || t('user.defaultRole') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits, ref } from 'vue';
|
||||
import { defineProps, defineEmits, ref, onMounted, onBeforeUnmount, inject, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
// Element Plus图标
|
||||
import { Bell, User, ArrowRight, Setting } from '@element-plus/icons-vue';
|
||||
|
||||
// 定义属性
|
||||
// 图标组件定义
|
||||
const BellIcon = Bell;
|
||||
const UserIcon = User;
|
||||
const ArrowRightIcon = ArrowRight;
|
||||
const SettingIcon = Setting;
|
||||
|
||||
// 注入主题配置
|
||||
const theme = inject('theme', { currentTheme: 'light' });
|
||||
|
||||
// 计算主题类名
|
||||
const themeClass = computed(() => `theme-${theme.currentTheme}`);
|
||||
|
||||
// 路由实例
|
||||
const router = useRouter();
|
||||
|
||||
// 组件属性
|
||||
const props = defineProps({
|
||||
userInfo: {
|
||||
type: Object,
|
||||
@@ -64,18 +88,235 @@ const props = defineProps({
|
||||
}
|
||||
});
|
||||
|
||||
// 定义事件
|
||||
// 组件事件
|
||||
const emit = defineEmits(['logout', 'notification-click', 'settings-click']);
|
||||
|
||||
// 默认头像
|
||||
// 状态管理
|
||||
const menuVisible = ref(false);
|
||||
const defaultAvatar = ref('https://picsum.photos/id/64/200/200');
|
||||
const { t } = useI18n();
|
||||
|
||||
// 切换菜单显示状态
|
||||
const toggleMenu = () => {
|
||||
menuVisible.value = !menuVisible.value;
|
||||
};
|
||||
|
||||
// 处理菜单选项
|
||||
const handleMenuSelect = (index) => {
|
||||
if (index === 'profile') {
|
||||
router.push('/profile');
|
||||
emit('settings-click');
|
||||
} else if (index === 'settings') {
|
||||
router.push('/settings');
|
||||
emit('settings-click');
|
||||
} else if (index === 'logout') {
|
||||
emit('logout');
|
||||
}
|
||||
menuVisible.value = false;
|
||||
};
|
||||
|
||||
// 点击外部关闭菜单
|
||||
const handleClickOutside = (event) => {
|
||||
const avatarContainer = document.querySelector('.avatar-container');
|
||||
if (avatarContainer && !avatarContainer.contains(event.target)) {
|
||||
menuVisible.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 生命周期钩子
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-info-bar {
|
||||
@apply z-10;
|
||||
/* 组件样式完全依赖全局主题变量 */
|
||||
.icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
img {width: 40px; height: 40px;}
|
||||
|
||||
.user-info-bar {
|
||||
height: 60px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
background-color: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
box-shadow: 0 1px 3px var(--color-shadow);
|
||||
z-index: 10;
|
||||
box-sizing: border-box;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
object-cover: cover;
|
||||
border: 2px solid var(--color-surface);
|
||||
box-shadow: 0 1px 2px var(--color-shadow);
|
||||
transition: transform 0.2s ease, border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.avatar-container:hover .avatar-img {
|
||||
transform: scale(1.05);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--color-surface);
|
||||
transition: background-color 0.3s ease, border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.user-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.user-text {
|
||||
display: block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.user-name-text {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.user-role-text {
|
||||
font-size: 12px;
|
||||
color: var(--color-text);
|
||||
opacity: 0.8;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: color 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
opacity: 0.8;
|
||||
font-size: 16px;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background-color: var(--color-surface-alt);
|
||||
color: var(--color-primary);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.notification-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: var(--color-error, #ef4444);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.user-dropdown-menu {
|
||||
position: absolute;
|
||||
right: -90px;
|
||||
top: calc(100% + 8px);
|
||||
min-width: 160px;
|
||||
z-index: 1000;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 10px var(--color-shadow);
|
||||
animation: fadeIn 0.2s ease;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* 下拉菜单主题适配 */
|
||||
:deep(.el-menu) {
|
||||
background-color: var(--color-surface) !important;
|
||||
border-color: var(--color-border) !important;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item) {
|
||||
color: var(--color-text) !important;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item:hover) {
|
||||
background-color: var(--color-surface-alt) !important;
|
||||
color: var(--color-primary) !important;
|
||||
}
|
||||
|
||||
:deep(.logout-item) {
|
||||
color: var(--color-error, #ef4444) !important;
|
||||
}
|
||||
|
||||
:deep(.logout-item:hover) {
|
||||
background-color: rgba(239, 68, 68, 0.1) !important;
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.1); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
|
||||
5
src/views/user/Profile.vue
Normal file
5
src/views/user/Profile.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
User Profile Page
|
||||
</template>
|
||||
<script setup>
|
||||
</script>
|
||||
6
src/views/uut/History.vue
Normal file
6
src/views/uut/History.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
History
|
||||
</template>
|
||||
<script>
|
||||
|
||||
</script>
|
||||
6
src/views/uut/Report.vue
Normal file
6
src/views/uut/Report.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
UUT Report
|
||||
</template>
|
||||
<script>
|
||||
|
||||
</script>
|
||||
@@ -6,6 +6,7 @@ import Components from 'unplugin-vue-components/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
|
||||
export default defineConfig({
|
||||
base: '/sbu4i4/',
|
||||
plugins: [
|
||||
vue(),
|
||||
AutoImport({
|
||||
|
||||
Reference in New Issue
Block a user