version: 20250815

This commit is contained in:
2025-08-15 12:47:58 +00:00
parent 14ff988b0b
commit d18a334626
27 changed files with 2419 additions and 610 deletions

View File

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

View File

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

@@ -6,5 +6,5 @@
// biome-ignore lint: disable
export {}
declare global {
const ElMessage: typeof import('element-plus/es')['ElMessage']
}

20
components.d.ts vendored
View File

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

View File

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

View File

@@ -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',
{
'/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) {
const userInfo = response.data;
// 关键修复:设置路由守卫依赖的登录状态
localStorage.setItem('isLoggedIn', 'true');
// 存储token如果后端返回了token
Cookies.set('access_token', userInfo.token || '');
localStorage.setItem('userInfo', crypto.encrypt(JSON.stringify(userInfo)));
// 关键修复强制解析响应为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;
}
}
// 使用解析后的响应判断
console.log('解析后响应:', parsedResponse, 'BOOL:', parsedResponse?.code === 200);
if (parsedResponse && parsedResponse.code === 200) {
const userInfo = parsedResponse.data;
console.log('用户信息:', userInfo);
// 存储登录状态
localStorage.setItem('isLoggedIn', 'true');
// 加密存储用户信息
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();
if (checkLoginStatus()) {
await router.push('/');
} else {
throw new Error('登录状态验证失败');
}
} catch (err) {
console.error('路由跳转失败:', err);
// 失败时的备选方案
window.location.href = '/main';
console.error('跳转失败:', err);
ElMessage.error('登录状态异常,请重试');
clearLoginState();
}
}, 800);
}, 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;

View File

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

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

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

View File

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

View File

@@ -1,4 +1,10 @@
export default {
theme: {
light: '亮色主题',
dark: '暗色主题',
custom: '自定义主题',
themeSwitched: '已切换到{theme}主题'
},
auth: {
loginTitle: '用户登录',
registerTitle: '用户注册',
@@ -10,7 +16,7 @@ export default {
passwordMinLength: '密码长度不能少于6个字符',
confirmPassword: '确认密码',
passwordMismatch: '两次输入的密码不一致',
usernamePlaceholder: '请输入用户名',
usernamePlaceholder: '请输入用户名',
passwordPlaceholder: '请输入密码',
loginBtn: '登录',
confirmPasswordPlaceholder: '请再次输入密码',
@@ -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: '打开通知中心'
}
}

View File

@@ -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');
if (isLoggedIn && hasUserInfo) {
const hasToken = !!Cookies.get('access_token');
return isLoggedIn && hasUserInfo && hasToken;
};
// 清除登录状态方法
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
View 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;

View File

@@ -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,13 +119,12 @@ api.interceptors.response.use(
// 处理业务逻辑错误
const { data } = response;
// 根据实际后端规范调整
if (data.code && data.code !== 200) {
ElMessage.error(data.message || '操作失败');
return Promise.reject(new Error(data.message || '请求失败'));
// 根据实际后端规范调整确保与后端code一致
if (data.code == null || ![0, 200].includes(data.code)) {
ElMessage.error(data.message || '操作失败');
return Promise.reject(new Error(data.message || '请求失败'));
}
return data;
},
async (error) => {
@@ -123,13 +142,10 @@ api.interceptors.response.use(
switch (status) {
case 401:
// 未授权处理token过期逻辑
ElMessage.error('登录已过期,请重新登录');
// 清除登录状态
Cookies.remove('access_token');
localStorage.removeItem('info');
// 跳转到登录页假设使用vue-router
if (window.router) {
window.router.push('/');
if (!router.currentRoute.value.path.includes('/auth')) {
ElMessage.error('登录已过期,请重新登录');
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
});
}

View File

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

View File

@@ -0,0 +1,6 @@
<template>
test
</template>
<script>
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -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>
<!-- 路由视图放在右侧内容区 -->
<RouterView />
</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');
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;
}
]);
// 当前页面操作按钮
const currentPageActions = ref([
{ label: '新增', icon: 'fa-plus', action: 'add' },
{ label: '导出', icon: 'fa-download', action: 'export' },
{ label: '刷新', icon: 'fa-refresh', action: 'refresh' }
]);
};
// 初始化
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 || '页面';
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;
if (path && path !== router.currentRoute.value.path) {
router.push(path);
}
};
@@ -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>

View File

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

View File

@@ -1,188 +1,85 @@
<template>
<el-menu
:default-active="activeMenu"
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 class="main-menu-container" :class="themeClass">
<!-- 侧边栏菜单 -->
<Sidebar
:is-collapse="sidebarCollapse"
@toggle-collapse="handleSidebarToggle"
class="sidebar-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 loading = ref(true);
const error = ref(false);
const rawMenuData = ref([]);
const menuData = ref([]);
const route = useRoute();
const router = useRouter();
// 国际化
const { t } = useI18n()
// 当前激活的菜单
const activeMenu = computed(() => {
return route.path;
});
// 控制侧边栏折叠状态
const sidebarCollapse = ref(false)
// 监听路由变化,更新激活菜单
watch(
() => route.path,
(newPath) => {
// 可以在这里添加额外的逻辑
}
);
// 计算主题类名
const themeClass = computed(() => `theme-${theme.currentTheme}`)
// 处理菜单选择
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();
});
// 处理侧边栏折叠/展开切换
const handleSidebarToggle = () => {
sidebarCollapse.value = !sidebarCollapse.value
}
</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>

View File

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

View File

@@ -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>
<!-- 用户信息 - 右侧布局 -->
<div class="user-info">
<!-- 头像与状态 -->
<div class="avatar-container" @click.stop="toggleMenu">
<img
:src="userInfo.avatar || defaultAvatar"
:alt="t('user.profile')"
class="avatar-img"
>
<span class="status-indicator" :title="t('user.online')"></span>
<!-- 弹出菜单 -->
<el-menu
v-if="menuVisible"
class="user-dropdown-menu"
mode="vertical"
@select="handleMenuSelect"
>
<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>
<button
class="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
@click="$emit('settings-click')"
aria-label="设置"
>
<i class="fa fa-cog text-lg"></i>
</button>
<button
class="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
@click="$emit('logout')"
aria-label="退出登录"
>
<i class="fa fa-sign-out text-lg"></i>
</button>
<!-- 基本信息 -->
<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>

View File

@@ -0,0 +1,5 @@
<template>
User Profile Page
</template>
<script setup>
</script>

View File

@@ -0,0 +1,6 @@
<template>
History
</template>
<script>
</script>

6
src/views/uut/Report.vue Normal file
View File

@@ -0,0 +1,6 @@
<template>
UUT Report
</template>
<script>
</script>

View File

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