Initial comitof pe project

This commit is contained in:
2025-08-13 01:55:49 +00:00
commit 05618f1cbb
40 changed files with 9432 additions and 0 deletions

8
.editorconfig Normal file
View File

@@ -0,0 +1,8 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

6
.env.development Normal file
View File

@@ -0,0 +1,6 @@
VITE_APP_API_BASE_URL='http://10.97.245.96'
# 32字节密钥AES-256
VITE_APP_KEY='cms.SBU4.PE$20@2aXz3kL9pR7sQ4fT1vB'
# 16字节向量
VITE_APP_IV='m8Zp2x7cK9bF3dS5'

7
.env.production Normal file
View File

@@ -0,0 +1,7 @@
VITE_APP_AUTH_API='http://192.168.8.8:9000'
VITE_APP_API_BASE_URL='http://192.168.8.8:9002'
# 32字节密钥AES-256
VITE_APP_KEY='cms.SBU4.PE$20@2aXz3kL9pR7sQ4fT1vB'
# 16字节向量
VITE_APP_IV='m8Zp2x7cK9bF3dS5'

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

30
.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

8
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"recommendations": [
"Vue.volar",
"vitest.explorer",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig"
]
}

41
README.md Normal file
View File

@@ -0,0 +1,41 @@
# pev1
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Compile and Minify for Production
```sh
npm run build
```
### Run Unit Tests with [Vitest](https://vitest.dev/)
```sh
npm run test:unit
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```

10
auto-imports.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
}

43
components.d.ts vendored Normal file
View File

@@ -0,0 +1,43 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
// biome-ignore lint: disable
export {}
/* prettier-ignore */
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']
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']
}
}

30
eslint.config.js Normal file
View File

@@ -0,0 +1,30 @@
import { defineConfig, globalIgnores } from 'eslint/config'
import globals from 'globals'
import js from '@eslint/js'
import pluginVue from 'eslint-plugin-vue'
import pluginVitest from '@vitest/eslint-plugin'
export default defineConfig([
{
name: 'app/files-to-lint',
files: ['**/*.{js,mjs,jsx,vue}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
{
languageOptions: {
globals: {
...globals.browser,
},
},
},
js.configs.recommended,
...pluginVue.configs['flat/essential'],
{
...pluginVitest.configs.recommended,
files: ['src/**/__tests__/*'],
},
])

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<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>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

8
jsconfig.json Normal file
View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

7051
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "pev1",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test:unit": "vitest",
"lint": "eslint . --fix"
},
"dependencies": {
"axios": "^1.11.0",
"crypto-js": "^4.2.0",
"element-plus": "^2.10.6",
"pinia": "^3.0.3",
"vue": "^3.5.18",
"vue-i18n": "^9.14.5",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@eslint/js": "^9.31.0",
"@vitejs/plugin-vue": "^6.0.1",
"@vitest/eslint-plugin": "^1.3.4",
"@vue/test-utils": "^2.4.6",
"eslint": "^9.31.0",
"eslint-plugin-vue": "~10.3.0",
"globals": "^16.3.0",
"jsdom": "^26.1.0",
"unplugin-auto-import": "^20.0.0",
"unplugin-vue-components": "^29.0.0",
"vite": "^7.0.6",
"vite-plugin-vue-devtools": "^8.0.0",
"vitest": "^3.2.4"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

26
src/App.vue Normal file
View File

@@ -0,0 +1,26 @@
<template>
<router-view />
</template>
<script setup>
// 路由配置已移至router/index.js
</script>
<style>
/* 引入全局主题样式 */
@import './assets/styles/global-theme.css';
/* 基础全局样式 */
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* 全局重置样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
</style>

11
src/__tests__/App.spec.js Normal file
View File

@@ -0,0 +1,11 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import App from '../App.vue'
describe('App', () => {
it('mounts renders properly', () => {
const wrapper = mount(App)
expect(wrapper.text()).toContain('You did it!')
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -0,0 +1,83 @@
/* 全局主题样式 - 应用于整个系统 */
/* 基础样式 */
body {
margin: 0;
padding: 0;
transition: background-color 0.3s ease, color 0.3s ease;
}
/* 亮色主题 */
.theme-light {
--color-primary: #2563eb;
--color-primary-dark: #1d4ed8;
--color-background: #ffffff;
--color-surface: #ffffff;
--color-surface-alt: #f3f4f6;
--color-text: #1e293b;
--color-text-alt: #ffffff;
--color-border: #e2e8f0;
--color-shadow: rgba(0, 0, 0, 0.2);
}
.theme-light body {
background-color: var(--color-background);
color: var(--color-text);
}
/* 暗色主题 */
.theme-dark {
--color-primary: #93c5fd;
--color-primary-dark: #bfdbfe;
--color-background: #0f172a;
--color-surface: #1e293b;
--color-surface-alt: #334155;
--color-text: #f8fafc;
--color-text-alt: #f8fafc;
--color-border: #334155;
--color-shadow: rgba(0, 0, 0, 0.5);
}
.theme-dark body {
background-color: var(--color-background);
color: var(--color-text);
}
/* 自定义主题 */
.theme-custom {
--color-primary: #166534;
--color-primary-dark: #059669;
--color-background: #f0fdf4;
--color-surface: #f0fdf4;
--color-surface-alt: #dcfce7;
--color-text: #1e293b;
--color-text-alt: #ffffff;
--color-border: #a7f3d0;
--color-shadow: rgba(22, 101, 52, 0.2);
}
.theme-custom body {
background-color: var(--color-background);
color: var(--color-text);
}
/* 全局组件样式适配主题 */
.el-button--primary {
background-color: var(--color-primary) !important;
border-color: var(--color-primary) !important;
}
.el-button--primary:hover {
background-color: var(--color-primary-dark) !important;
border-color: var(--color-primary-dark) !important;
}
.el-input__inner, .el-select__wrapper {
background-color: var(--color-surface) !important;
border-color: var(--color-border) !important;
color: var(--color-text) !important;
}
.el-form-item__label {
color: var(--color-text) !important;
}

View File

@@ -0,0 +1,441 @@
<template>
<div class="page-container">
<!-- 全局背景图片 -->
<div
class="global-background"
:class="{ 'bg-blue-50': !backgroundImageLoaded }"
:style="backgroundImageStyle"
></div>
<!-- 顶部操作区 -->
<div class="top-controls">
<!-- 主题切换按钮 -->
<div class="theme-switch">
<el-select
v-model="currentTheme"
size="small"
@change="handleThemeChange"
class="theme-select"
>
<el-option label="亮色主题" value="light"></el-option>
<el-option label="暗色主题" value="dark"></el-option>
<el-option label="自定义主题" value="custom"></el-option>
</el-select>
</div>
<!-- 语言切换按钮 -->
<div class="language-switch flex items-center gap-2" v-tooltip:bottom="$t('common.switchLang')">
<el-switch
v-model="currentLanguage"
size="big"
active-value="en-US"
inline-prompt
inactive-value="zh-CN"
@change="toggleLanguage"
active-text="中文"
inactive-text="English"
style="--el-switch-on-color: #13ce66; --el-switch-off-color: #ee00ff"
/>
</div>
</div>
<!-- 登录注册窗口 -->
<div class="auth-container">
<!-- 表单内容 -->
<div class="form-content">
<!-- 标题栏 -->
<div class="title-bar">
<h2 class="title-text">
{{ isLoginMode ? $t('auth.loginTitle') : $t('auth.registerTitle') }}
</h2>
</div>
<!-- 表单容器 -->
<div class="form-container">
<LoginForm
v-if="isLoginMode"
@submit-form="handleLogin"
:loading="loginLoading"
/>
<RegisterForm v-else @submit-form="handleRegister" />
</div>
<!-- 切换按钮 -->
<div class="mode-switch">
<el-button
type="text"
@click="toggleMode"
class="switch-btn"
>
<span class="switch-text">
{{ isLoginMode ? $t('auth.noAccount') : $t('auth.haveAccount') }}
</span>
</el-button>
</div>
</div>
</div>
</div>
</template>
<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';
import { getLocale, setLocale } from '@/utils/storage';
import request from '@/utils/api';
import Cookies from 'js-cookie';
import backgroundImage from '@/assets/background/vtech.png';
// 初始化i18n
const { locale, t } = useI18n();
// 路由实例
const router = useRouter();
// 主题相关
const theme = inject('theme', null);
const currentTheme = ref(theme?.currentTheme || 'light');
// 状态管理
const isLoginMode = ref(true);
const backgroundImageLoaded = ref(false);
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();
loadBackgroundImage();
checkRememberedLogin();
});
// 检查记住的登录信息
const checkRememberedLogin = () => {
const savedUser = localStorage.getItem('rememberedUser');
if (savedUser) {
try {
const userData = JSON.parse(crypto.decrypt(savedUser));
// 移除调试输出
} catch (e) {
// 生产环境不输出错误
localStorage.removeItem('rememberedUser');
}
}
};
// 处理主题切换
const handleThemeChange = (newTheme) => {
if (theme && theme.switchTheme) {
theme.switchTheme(newTheme);
currentTheme.value = newTheme;
}
};
// 加载背景图片的方法
const loadBackgroundImage = () => {
try {
const imgUrl = backgroundImage;
const img = new Image();
img.src = imgUrl;
img.onload = () => {
backgroundImageLoaded.value = true;
backgroundImageStyle.value = {
backgroundImage: `url(${imgUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
backgroundAttachment: 'fixed',
opacity: 1.0,
};
};
img.onerror = () => {
handleImageError();
};
} catch (e) {
handleImageError();
}
};
const handleImageError = () => {
backgroundImageLoaded.value = false;
backgroundImageStyle.value = {
backgroundColor: '#f0f9ff',
};
};
// 切换登录/注册模式
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');
} else if (error.message.includes('Network Error')) {
return t('auth.networkError');
} else if (error.response?.status === 401) {
return t('auth.unauthorized');
} else if (error.response?.status === 403) {
return t('auth.forbidden');
} else if (error.response?.status === 404) {
return t('auth.notFound');
} else if (error.response?.status >= 500) {
return t('auth.serverError');
} else if (error.response?.data) {
return error.response.data.message || error.response.data.msg || t('auth.requestFailed');
} else {
return error.message || t('auth.requestFailed');
}
}
// 处理普通对象错误
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;
}
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) {
const userInfo = response.data;
// 关键修复:设置路由守卫依赖的登录状态
localStorage.setItem('isLoggedIn', 'true');
// 存储token如果后端返回了token
Cookies.set('access_token', userInfo.token || '');
localStorage.setItem('userInfo', crypto.encrypt(JSON.stringify(userInfo)));
if (formData.rememberMe) {
localStorage.setItem('rememberedUser', crypto.encrypt(JSON.stringify({
username: formData.username,
rememberMe: true
})));
} 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';
}
}, 800);
} else {
const errorMsg = response?.message || t('auth.loginFailed');
ElMessage.error(errorMsg);
}
} catch (error) {
const errorMsg = getErrorMessage(error);
ElMessage.error(errorMsg);
} finally {
loginLoading.value = false;
}
};
// 语言切换
const toggleLanguage = (newLocale) => {
locale.value = newLocale;
setLocale(newLocale);
currentLanguage.value = newLocale;
ElMessage.success(t('common.languageChanged'));
};
</script>
<style scoped>
.page-container {
position: relative;
min-height: 100vh;
width: 100%;
}
.global-background {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 0;
transition: opacity 0.5s ease-in-out;
}
.auth-container {
position: fixed;
border: 1px solid #c2c4c2;
right: 16px;
bottom: 10px;
width: 360px;
height: 390px;
border-radius: 16px;
box-shadow: 0 10px 25px -5px var(--color-shadow);
background-color: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(4px);
overflow: hidden;
transition: all 0.3s ease;
z-index: 50;
}
:deep(.dark-theme) .auth-container {
background-color: rgba(30, 30, 40, 0.9);
border-color: #444;
}
.top-controls {
position: absolute;
top: 16px;
right: 16px;
left: 16px;
display: flex;
justify-content: flex-end;
gap: 8px;
z-index: 30;
}
.theme-switch {
margin-right: auto;
}
.theme-select {
width: 120px;
background-color: rgba(255, 255, 255, 0.8);
border-radius: 20px;
}
.language-switch {
margin-left: auto;
}
.form-content {
position: relative;
z-index: 20;
width: 100%;
height: 100%;
}
.title-bar {
padding: 24px;
color: var(--color-text-alt);
}
.title-text {
font-size: 24px;
font-weight: bold;
text-align: center;
margin: 0;
}
.mode-switch {
position: absolute;
left: 50%;
bottom: 2px;
transform: translateX(-50%);
width: 100%;
padding: 12px 12px;
display: flex;
justify-content: center;
background-color: inherit;
z-index: 25;
box-sizing: border-box;
}
.switch-btn {
font-weight: 500 !important;
display: flex !important;
align-items: center !important;
padding: 0 !important;
color: var(--color-primary) !important;
}
.switch-btn:hover {
color: var(--color-primary-dark) !important;
}
.switch-text {
margin-right: 8px;
}
.form-container {
height: 180px;
padding: 32px;
color: var(--color-text);
}
@media (max-width: 480px) {
.auth-container {
right: 0;
bottom: 0;
max-width: 100%;
border-radius: 16px 16px 0 0;
height: auto;
min-height: 80vh;
}
.form-container {
padding: 16px;
min-height: 300px;
}
}
</style>

View File

@@ -0,0 +1,202 @@
<template>
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
class="login-form"
>
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
:placeholder="$t('auth.usernamePlaceholder')"
>
<template #prefix>
<el-icon class="el-input__icon"><User /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
:placeholder="$t('auth.passwordPlaceholder')"
type="password"
show-password
>
<template #prefix>
<el-icon class="el-input__icon"><Lock /></el-icon>
</template>
</el-input>
</el-form-item>
<!-- 合并记住我和登录按钮到同一行 -->
<el-form-item class="form-actions-row">
<div class="flex-container">
<!-- 记住我选项 -->
<div class="remember-me">
<el-checkbox v-model="rememberMe" :label="$t('auth.rememberMe')" class="remember-me-checkbox">
{{ $t('auth.rememberMe') }}
</el-checkbox>
</div>
<!-- 登录按钮 - 强制靠右 -->
<div class="button-spacer"></div>
<div class="button-wrapper">
<el-button
type="primary"
@click="handleSubmit"
>
{{ $t('auth.loginBtn') }}
</el-button>
</div>
</div>
</el-form-item>
</el-form>
</template>
<script setup>
import { ref, defineEmits, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { User, Lock } from '@element-plus/icons-vue';
// 表单数据
const loginForm = ref({
username: '',
password: ''
});
// 记住我选项
const rememberMe = ref(false);
// 表单引用
const loginFormRef = ref(null);
// 国际化
const { t } = useI18n();
// 添加props定义
const props = defineProps({
loading: {
type: Boolean,
default: false
}
});
// 初始化 - 检查是否有记住的登录信息
onMounted(() => {
const savedUser = localStorage.getItem('rememberedUser');
if (savedUser) {
try {
const userData = JSON.parse(savedUser);
loginForm.value.username = userData.username || '';
loginForm.value.password = userData.password || '';
rememberMe.value = true;
} catch (e) {
console.error('Failed to parse remembered user data', e);
localStorage.removeItem('rememberedUser');
}
}
});
// 表单验证规则
const loginRules = ref({
username: [
{ required: true, message: t('auth.username'), trigger: 'blur' },
{ min: 6, max: 12, message: t('auth.usernameRule'), trigger: 'blur' }
],
password: [
{ required: true, message: t('auth.password'), trigger: 'blur' },
{ min: 6, message: t('auth.passwordRule'), trigger: 'blur' }
]
});
// 定义事件
const emit = defineEmits(['submit-form']);
// 提交表单
const handleSubmit = () => {
loginFormRef.value.validate((valid) => {
if (valid) {
// 处理记住我功能
if (rememberMe.value) {
localStorage.setItem('rememberedUser', JSON.stringify({
username: loginForm.value.username,
password: loginForm.value.password
}));
} else {
localStorage.removeItem('rememberedUser');
}
emit('submit-form', { ...loginForm.value });
} else {
console.log('表单验证失败');
return false;
}
});
};
</script>
<style scoped>
.login-form {
width: 100%;
}
/* 关键使用flex容器并移除所有默认间距 */
.form-actions-row {
padding: 0 !important;
margin: 20px 0 0 0 !important;
}
/* 内部flex容器控制对齐 */
.flex-container {
display: flex;
width: 100%;
align-items: center;
}
/* 记住我选项靠左 */
.remember-me {
margin: 0;
padding: 0;
}
/* 记住我字体颜色设置 */
.remember-me-checkbox {
--el-checkbox-text-color: var(--color-text); /* 使用主题文本色 */
/* 或者使用自定义颜色:
--el-checkbox-text-color: #666; */
font-size: 14px; /* 可选:调整字体大小 */
}
/* 填充空间,将按钮推到右侧 */
.button-spacer {
flex-grow: 1;
}
/* 按钮容器确保按钮靠右 */
.button-wrapper {
margin: 0;
padding: 0;
display: flex;
justify-content: flex-end;
}
/* 按钮样式 */
.el-button {
width: 120px;
margin: 0 !important;
}
/* 强制覆盖Element Plus的默认样式 */
:deep(.el-form-item__content) {
margin: 0 !important;
padding: 0 !important;
width: 100% !important;
}
/* 图标样式 */
.el-input__icon {
font-size: 18px;
}
</style>

View File

@@ -0,0 +1,135 @@
<template>
<el-form
ref="registerFormRef"
:model="registerForm"
:rules="registerRules"
class="register-form"
>
<el-form-item prop="username">
<el-input
v-model="registerForm.username"
:placeholder="$t('auth.usernamePlaceholder')"
prefix-icon="User"
></el-input>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="registerForm.password"
:placeholder="$t('auth.passwordPlaceholder')"
type="password"
prefix-icon="Lock"
></el-input>
</el-form-item>
<el-form-item prop="confirmPassword">
<el-input
v-model="registerForm.confirmPassword"
:placeholder="$t('auth.confirmPasswordPlaceholder')"
type="password"
prefix-icon="Lock"
></el-input>
</el-form-item>
<!-- 使用额外的容器包裹按钮 -->
<el-form-item class="form-actions">
<div class="button-wrapper">
<el-button
type="primary"
@click="handleSubmit"
>
{{ $t('auth.registerBtn') }}
</el-button>
</div>
</el-form-item>
</el-form>
</template>
<script setup>
import { ref, defineEmits } from 'vue';
import { useI18n } from 'vue-i18n';
// 表单数据
const registerForm = ref({
username: '',
password: '',
confirmPassword: ''
});
// 表单引用
const registerFormRef = ref(null);
// 国际化
const { t } = useI18n();
// 表单验证规则
const registerRules = ref({
username: [
{ required: true, message: t('auth.username'), trigger: 'blur' },
{ min: 6, max: 12, message: t('auth.usernameRule'), trigger: 'blur' }
],
password: [
{ required: true, message: t('auth.password'), trigger: 'blur' },
{ min: 6, message: t('auth.passwordRule'), trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: t('auth.confirmPassword'), trigger: 'blur' },
{
validator: (rule, value, callback) => {
if (value !== registerForm.value.password) {
callback(new Error(t('auth.confirmRule')));
} else {
callback();
}
},
trigger: 'blur'
}
]
});
// 定义事件
const emit = defineEmits(['submit-form']);
// 提交表单
const handleSubmit = () => {
registerFormRef.value.validate((valid) => {
if (valid) {
// 表单验证通过,提交数据
const formData = {
username: registerForm.value.username,
password: registerForm.value.password
};
emit('submit-form', formData);
} else {
console.log('表单验证失败');
return false;
}
});
};
</script>
<style scoped>
.register-form {
width: 100%;
}
.form-actions {
margin-top: 20px;
/* 移除 Element Plus 表单项的默认内边距 */
padding: 0 !important;
margin-bottom: 0 !important;
}
/* 使用额外容器并穿透 scoped 样式限制 */
.form-actions :deep(.button-wrapper) {
display: flex;
justify-content: flex-end;
width: 100%;
}
/* 可选:设置按钮宽度,与登录按钮保持一致 */
.el-button {
width: 120px;
}
</style>

19
src/i18n/index.js Normal file
View File

@@ -0,0 +1,19 @@
import { createI18n } from 'vue-i18n';
import zhCN from './lang/zh-CN';
import enUS from './lang/en-US';
import { getLocale } from '../utils/storage';
// 确保禁用legacy模式
const i18n = createI18n({
legacy: false,
globalInjection: true, // 允许在模板中使用$t
locale: getLocale() || 'zh-CN',
fallbackLocale: 'zh-CN',
messages: {
'zh-CN': zhCN,
'en-US': enUS
}
});
export default i18n;

36
src/i18n/lang/en-US.js Normal file
View File

@@ -0,0 +1,36 @@
export default {
auth: {
loginTitle: 'User Login',
registerTitle: 'User Registration',
noAccount: "Don't have an account? Register",
haveAccount: 'Already have an account? Login',
username: 'Username',
usernameLength: 'Username must be 6-12 characters',
password: 'Password',
passwordMinLength: 'Password must be at least 6 characters',
confirmPassword: 'Confirm Password',
passwordMismatch: 'Passwords do not match',
usernamePlaceholder: 'User name',
passwordPlaceholder: 'Password',
loginBtn: 'Login',
confirmPasswordPlaceholder: 'Please confirm your password',
registerBtn: 'Register',
rememberMe: 'Remember Me',
loginSuccess: 'Login successful',
loginFailed: 'Login failed, please try again',
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'
},
common: {
login: 'Login',
register: 'Register',
switchLang: 'Switch Language'
},
el: {
form: {
required: 'Please fill in the required field'
}
}
}

54
src/i18n/lang/zh-CN.js Normal file
View File

@@ -0,0 +1,54 @@
export default {
auth: {
loginTitle: '用户登录',
registerTitle: '用户注册',
noAccount: '还没有账号?去注册',
haveAccount: '已有账号?去登录',
username: '用户名',
usernameLength: '用户名长度必须为6-12个字符',
password: '密码',
passwordMinLength: '密码长度不能少于6个字符',
confirmPassword: '确认密码',
passwordMismatch: '两次输入的密码不一致',
usernamePlaceholder: '请输入用户名',
passwordPlaceholder: '请输入密码',
loginBtn: '登录',
confirmPasswordPlaceholder: '请再次输入密码',
registerBtn: '注册',
rememberMe: '记住我',
loginSuccess: '登录成功',
loginFailed: '登录失败,请重试',
registerSuccess: '注册成功',
registerFailed: '注册失败,请重试',
invalidCredentials: '用户名或密码错误',
serverError: '服务器错误,请稍后再试',
networkError: '网络连接错误,请检查网络',
unknownError: '发生未知错误,请重试',
// 新增错误类型翻译
requestTimeout: '请求超时,请稍后重试',
unauthorized: '未授权访问,请重新登录',
forbidden: '禁止访问,没有权限',
notFound: '请求的资源不存在',
requestFailed: '请求处理失败',
// 可能需要的其他注册相关提示
usernameExists: '用户名已存在',
emailExists: '邮箱已被注册',
invalidUsername: '用户名格式不正确',
invalidPassword: '密码格式不符合要求'
},
common: {
login: '登录',
register: '注册',
switchLang: '切换语言',
languageChanged: '语言已切换',
close: '关闭',
confirm: '确认',
cancel: '取消'
},
el: {
form: {
required: '请输入必填项'
}
}
}

51
src/main.js Normal file
View File

@@ -0,0 +1,51 @@
import { createApp } from 'vue';
import App from './App.vue';
import i18n from './i18n';
import router from './router'; // 引入路由
import { initTheme, switchTheme, ThemeTypes } from '@/utils/theme';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
// 导入所有Element Plus图标
import * as ElementPlusIconsVue from '@element-plus/icons-vue';
// 初始化主题
const initialTheme = initTheme();
const app = createApp(App);
// 全局注册所有Element Plus图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
}
// 提供全局主题控制
app.provide('theme', {
currentTheme: initialTheme,
switchTheme,
ThemeTypes
});
// 提供认证相关方法,添加路由跳转
app.provide('login', (formData) => {
console.log('登录数据:', formData);
// 模拟登录成功
localStorage.setItem('isLoggedIn', 'true');
localStorage.setItem('username', formData.username);
// 跳转到主页面
router.push('/main');
});
app.provide('register', (formData) => {
console.log('注册数据:', formData);
// 模拟注册成功后自动登录
localStorage.setItem('isLoggedIn', 'true');
localStorage.setItem('username', formData.username);
// 跳转到主页面
router.push('/main');
});
// 使用路由
app.use(router)
.use(i18n)
.use(ElementPlus)
.mount('#app');

63
src/router/index.js Normal file
View File

@@ -0,0 +1,63 @@
import { createRouter, createWebHistory } from 'vue-router';
import AuthPage from '../components/authentication/AuthPage.vue';
import MainPage from '../views/mainpage/index.vue';
// 路由守卫:检查是否登录 - 优化版
const requireAuth = (to, from, next) => {
// 更可靠的登录状态检查
const isLoggedIn = localStorage.getItem('isLoggedIn') === 'true';
const hasUserInfo = !!localStorage.getItem('userInfo');
if (isLoggedIn && hasUserInfo) {
next();
} else {
// 清除无效的登录状态
localStorage.removeItem('isLoggedIn');
next('/');
}
};
const routes = [
{
path: '/',
name: 'Auth',
component: AuthPage,
beforeEnter: (to, from, next) => {
const isLoggedIn = localStorage.getItem('isLoggedIn') === 'true';
const hasUserInfo = !!localStorage.getItem('userInfo');
if (isLoggedIn && hasUserInfo) {
next('/main');
} else {
next();
}
}
},
{
path: '/main',
name: 'Main',
component: MainPage,
beforeEnter: requireAuth
},
{
path: '/:pathMatch(.*)*',
redirect: '/'
}
];
const router = createRouter({
history: createWebHistory(),
routes
});
// 全局路由守卫,处理可能的登录状态异常
router.beforeEach((to, from, next) => {
// 对所有需要授权的路由进行检查
if (to.matched.some(record => record.beforeEnter === requireAuth)) {
requireAuth(to, from, next);
} else {
next();
}
});
export default router;

12
src/stores/counter.js Normal file
View File

@@ -0,0 +1,12 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})

238
src/utils/api/index.js Normal file
View File

@@ -0,0 +1,238 @@
import axios from "axios";
import crypto from "@/utils/crypto";
import Cookies from 'js-cookie';
import { ElMessage, ElLoading } from 'element-plus';
// 获取基础 API 地址
const API_BASE_URL = import.meta.env.VITE_APP_API_BASE_URL;
if (!API_BASE_URL) {
console.warn('API基础地址未配置请在环境变量中设置VITE_APP_API_BASE_URL');
}
// 创建axios实例
const api = axios.create({
baseURL: API_BASE_URL,
timeout: 15000, // 请求超时时间
headers: {
'Content-Type': 'application/json'
}
});
// 加载状态管理
let loadingInstance = null;
let requestCount = 0;
// 显示加载状态
const showLoading = () => {
if (requestCount === 0) {
loadingInstance = ElLoading.service({
lock: true,
text: '加载中...',
background: 'rgba(0, 0, 0, 0.1)'
});
}
requestCount++;
};
// 隐藏加载状态
const hideLoading = () => {
requestCount--;
if (requestCount <= 0 && loadingInstance) {
loadingInstance.close();
loadingInstance = null;
requestCount = 0;
}
};
// 请求拦截器
api.interceptors.request.use(
(config) => {
// 如果需要显示加载状态
if (config.showLoading !== false) {
showLoading();
}
// 添加认证token
const accessToken = Cookies.get('access_token');
if (accessToken) {
config.headers['Authorization'] = `Bearer ${accessToken}`;
}
// 处理POST请求加密
if (config.method?.toLowerCase() === 'post' && !config.isEncrypted) {
try {
// 添加用户信息
let requestData = { ...config.data };
const storedUserInfo = localStorage.getItem('info');
if (storedUserInfo) {
const info = JSON.parse(crypto.decrypt(storedUserInfo));
requestData['user_id'] = info.id;
}
// 加密数据
config.data = {
postdata: crypto.encrypt(JSON.stringify(requestData))
};
// 标记为已加密
config.isEncrypted = true;
} catch (error) {
console.error('请求数据加密失败:', error);
}
}
return config;
},
(error) => {
hideLoading();
ElMessage.error('请求配置错误');
return Promise.reject(error);
}
);
// 响应拦截器
api.interceptors.response.use(
(response) => {
hideLoading();
// 处理业务逻辑错误
const { data } = response;
// 根据实际后端规范调整
if (data.code && data.code !== 200) {
ElMessage.error(data.message || '操作失败');
return Promise.reject(new Error(data.message || '请求失败'));
}
return data;
},
async (error) => {
hideLoading();
// 处理网络错误
if (!error.response) {
ElMessage.error('网络连接异常,请检查网络');
return Promise.reject(error);
}
const { status, data } = error.response;
// 处理不同状态码
switch (status) {
case 401:
// 未授权处理token过期逻辑
ElMessage.error('登录已过期,请重新登录');
// 清除登录状态
Cookies.remove('access_token');
localStorage.removeItem('info');
// 跳转到登录页假设使用vue-router
if (window.router) {
window.router.push('/');
}
break;
case 403:
ElMessage.error('没有权限执行此操作');
break;
case 404:
ElMessage.error('请求的资源不存在');
break;
case 500:
ElMessage.error('服务器内部错误,请稍后再试');
break;
default:
ElMessage.error(data?.message || `请求错误 (${status})`);
}
return Promise.reject(error);
}
);
// 封装请求方法
const request = {
/**
* GET请求
* @param {string} url 请求地址
* @param {object} params 请求参数
* @param {object} config 额外配置
* @returns {Promise}
*/
get: (url, params = {}, config = {}) => {
return api({
url,
method: 'get',
params,
...config
});
},
/**
* POST请求
* @param {string} url 请求地址
* @param {object} data 请求数据
* @param {object} config 额外配置
* @returns {Promise}
*/
post: (url, data = {}, config = {}) => {
return api({
url,
method: 'post',
data,
...config
});
},
/**
* PUT请求
* @param {string} url 请求地址
* @param {object} data 请求数据
* @param {object} config 额外配置
* @returns {Promise}
*/
put: (url, data = {}, config = {}) => {
return api({
url,
method: 'put',
data,
...config
});
},
/**
* DELETE请求
* @param {string} url 请求地址
* @param {object} params 请求参数
* @param {object} config 额外配置
* @returns {Promise}
*/
delete: (url, params = {}, config = {}) => {
return api({
url,
method: 'delete',
params,
...config
});
},
/**
* 不加密的POST请求
* @param {string} url 请求地址
* @param {object} data 请求数据
* @param {object} config 额外配置
* @returns {Promise}
*/
postWithoutEncryption: (url, data = {}, config = {}) => {
return api({
url,
method: 'post',
data,
isEncrypted: true, // 标记为已加密,跳过加密处理
...config
});
}
};
// 暴露axios实例和请求方法
export { api, request };
export default request;

30
src/utils/crypto/index.js Normal file
View File

@@ -0,0 +1,30 @@
import CryptoJS from '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 crypto = {
encrypt: (str) => {
const encrypted = CryptoJS.AES.encrypt(str, 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);
}
};
export default crypto;

View File

@@ -0,0 +1,13 @@
// 存储键名常量
const LOCALE_KEY = 'app_locale' // 用于保存用户选择的语言
// 获取本地存储的语言设置
export const getLocale = () => {
// 优先从本地存储获取,默认返回 'zh-CN'
return localStorage.getItem(LOCALE_KEY) || 'zh-CN'
}
// 保存语言设置到本地存储
export const setLocale = (locale) => {
localStorage.setItem(LOCALE_KEY, locale)
}

41
src/utils/theme/index.js Normal file
View File

@@ -0,0 +1,41 @@
// 主题类型定义
export const ThemeTypes = {
LIGHT: 'light',
DARK: 'dark',
CUSTOM: 'custom'
};
// 应用主题到整个应用
export const applyGlobalTheme = (theme) => {
const html = document.documentElement;
// 移除所有主题类
Object.values(ThemeTypes).forEach(type => {
html.classList.remove(`theme-${type}`);
});
// 添加当前主题类
html.classList.add(`theme-${theme}`);
};
// 初始化主题
export const initTheme = () => {
const savedTheme = localStorage.getItem('app_theme');
const defaultTheme = ThemeTypes.LIGHT;
const themeToApply = savedTheme || defaultTheme;
applyGlobalTheme(themeToApply);
return themeToApply;
};
// 切换主题
export const switchTheme = (theme) => {
if (!Object.values(ThemeTypes).includes(theme)) {
console.warn('无效的主题类型');
return;
}
applyGlobalTheme(theme);
localStorage.setItem('app_theme', theme);
return theme;
};

View File

@@ -0,0 +1,228 @@
<template>
<div class="main-container">
<!-- 左侧菜单栏组件 -->
<div class="menu-view">
<MainMenu
:menu-items="menuItems"
:collapsed="isSidebarCollapsed"
@toggle="isSidebarCollapsed = !isSidebarCollapsed"
@menu-click="handleMenuChange"
/>
</div>
<!-- 右侧主内容区 -->
<div class="right-view">
<div class="top-view">
<!-- 顶部用户信息栏组件 -->
<UserInfoBar
:user-info="currentUser"
:unread-count="unreadNotifications"
@logout="handleLogout"
@notification-click="handleNotificationClick"
@settings-click="handleSettingsClick"
/>
</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 { useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
import MainMenu from './layout/MainMenu.vue';
import UserInfoBar from './layout/UserInfoBar.vue';
import ContentArea from './layout/ContentArea.vue';
import ContentHeader from './layout/ContentHeader.vue';
// 模拟API函数实际项目中应从api文件导入
const getUserInfo = () => Promise.resolve({
name: '管理员',
role: '系统管理员',
avatar: 'https://picsum.photos/id/64/200/200'
});
const logout = () => Promise.resolve();
// 状态管理
const isSidebarCollapsed = ref(false);
const unreadNotifications = ref(3);
const currentPageTitle = ref('仪表盘');
const currentUser = ref({});
// 路由实例
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
}
]);
// 当前页面操作按钮
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();
currentUser.value = userData;
setActiveMenuByRoute(router.currentRoute.value.path);
} catch (error) {
ElMessage.error('加载用户信息失败');
console.error(error);
}
});
// 根据路由设置活跃菜单
const setActiveMenuByRoute = (path) => {
menuItems.forEach(item => {
item.active = item.path === path;
if (item.children && item.children.length) {
item.children.forEach(subItem => {
subItem.active = subItem.path === path;
});
item.expanded = item.children.some(sub => sub.active);
}
});
};
// 处理菜单切换
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;
}
};
// 处理退出登录
const handleLogout = async () => {
try {
await logout();
router.push('/login');
ElMessage.success('退出登录成功');
} catch (error) {
ElMessage.error('退出登录失败');
}
};
// 处理通知点击
const handleNotificationClick = () => {
router.push('/main/notifications');
unreadNotifications.value = 0;
};
// 处理设置点击
const handleSettingsClick = () => {
router.push('/main/settings');
};
</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; /* 防止页面溢出 */
}
.menu-view {
/* 移除absolute定位使用flex布局 */
width: 200px;
height: 100%;
border: 1px solid #ff0000;
flex-shrink: 0; /* 防止菜单被压缩 */
}
.right-view {
flex: 1; /* 自动填充剩余空间 */
height: 100%;
border: 1px solid #00ff00;
display: flex;
flex-direction: column; /* 垂直排列顶部和内容区 */
}
.top-view {
height: 60px; /* 固定顶部高度 */
border-bottom: 1px solid #ccc;
}
.connect-view {
flex: 1; /* 填充剩余高度 */
overflow: auto; /* 内容溢出时可滚动 */
}
</style>

View File

@@ -0,0 +1,22 @@
<template>
<div class="content-area flex-1 flex flex-col overflow-hidden">
<!-- 内容头部插槽 -->
<slot name="header"></slot>
<!-- 主内容区域 -->
<main class="flex-1 overflow-auto bg-gray-50 dark:bg-gray-900">
<slot></slot>
</main>
</div>
</template>
<script setup>
// 内容区域容器,仅提供布局结构
</script>
<style scoped>
.content-area {
@apply relative;
}
</style>

View File

@@ -0,0 +1,42 @@
<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

@@ -0,0 +1,188 @@
<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>
</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';
// 组件属性
const props = defineProps({
isCollapse: {
type: Boolean,
default: false
}
});
// 状态管理
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>
.sidebar-menu {
height: 100%;
border-right: none;
}
.error-alert {
margin: 10px;
}
.retry-btn {
margin-left: 10px;
color: #409eff;
}
</style>

View File

@@ -0,0 +1,68 @@
<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

@@ -0,0 +1,81 @@
<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">
<button
class="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors relative"
@click="$emit('notification-click')"
aria-label="通知"
>
<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">
{{ unreadCount > 9 ? '9+' : unreadCount }}
</span>
</button>
<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>
</header>
</template>
<script setup>
import { defineProps, defineEmits, ref } from 'vue';
// 定义属性
const props = defineProps({
userInfo: {
type: Object,
default: () => ({})
},
unreadCount: {
type: Number,
default: 0
}
});
// 定义事件
const emit = defineEmits(['logout', 'notification-click', 'settings-click']);
// 默认头像
const defaultAvatar = ref('https://picsum.photos/id/64/200/200');
</script>
<style scoped>
.user-info-bar {
@apply z-10;
}
img {width: 40px; height: 40px;}
</style>

38
vite.config.js Normal file
View File

@@ -0,0 +1,38 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver()],
}),
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
},
server: {
host: '0.0.0.0',
port: 8088,
open: false,
cors: true,
// 关键修正将proxy移到server内部
proxy: {
'/api': {
target: 'http://10.97.245.96', // 后端API地址
changeOrigin: true, // 重要:模拟同源请求
// 可选:如果后端接口本身不带/api前缀需要去掉
// rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
})

14
vitest.config.js Normal file
View File

@@ -0,0 +1,14 @@
import { fileURLToPath } from 'node:url'
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
import viteConfig from './vite.config'
export default mergeConfig(
viteConfig,
defineConfig({
test: {
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/**'],
root: fileURLToPath(new URL('./', import.meta.url)),
},
}),
)