Initial comitof pe project
This commit is contained in:
8
.editorconfig
Normal file
8
.editorconfig
Normal 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
6
.env.development
Normal 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
7
.env.production
Normal 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
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal 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
8
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"vitest.explorer",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"EditorConfig.EditorConfig"
|
||||
]
|
||||
}
|
||||
41
README.md
Normal file
41
README.md
Normal 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
10
auto-imports.d.ts
vendored
Normal 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
43
components.d.ts
vendored
Normal 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
30
eslint.config.js
Normal 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
13
index.html
Normal 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
8
jsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
7051
package-lock.json
generated
Normal file
7051
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
package.json
Normal file
40
package.json
Normal 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
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
26
src/App.vue
Normal file
26
src/App.vue
Normal 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
11
src/__tests__/App.spec.js
Normal 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!')
|
||||
})
|
||||
})
|
||||
BIN
src/assets/background/vtech.png
Normal file
BIN
src/assets/background/vtech.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
83
src/assets/styles/global-theme.css
Normal file
83
src/assets/styles/global-theme.css
Normal 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;
|
||||
}
|
||||
441
src/components/authentication/AuthPage.vue
Normal file
441
src/components/authentication/AuthPage.vue
Normal 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>
|
||||
202
src/components/authentication/LoginForm.vue
Normal file
202
src/components/authentication/LoginForm.vue
Normal 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>
|
||||
|
||||
135
src/components/authentication/RegisterForm.vue
Normal file
135
src/components/authentication/RegisterForm.vue
Normal 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
19
src/i18n/index.js
Normal 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
36
src/i18n/lang/en-US.js
Normal 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
54
src/i18n/lang/zh-CN.js
Normal 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
51
src/main.js
Normal 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
63
src/router/index.js
Normal 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
12
src/stores/counter.js
Normal 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
238
src/utils/api/index.js
Normal 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
30
src/utils/crypto/index.js
Normal 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;
|
||||
13
src/utils/storage/index.js
Normal file
13
src/utils/storage/index.js
Normal 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
41
src/utils/theme/index.js
Normal 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;
|
||||
};
|
||||
228
src/views/mainpage/index.vue
Normal file
228
src/views/mainpage/index.vue
Normal 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>
|
||||
|
||||
22
src/views/mainpage/layout/ContentArea.vue
Normal file
22
src/views/mainpage/layout/ContentArea.vue
Normal 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>
|
||||
|
||||
42
src/views/mainpage/layout/ContentHeader.vue
Normal file
42
src/views/mainpage/layout/ContentHeader.vue
Normal 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>
|
||||
|
||||
188
src/views/mainpage/layout/MainMenu.vue
Normal file
188
src/views/mainpage/layout/MainMenu.vue
Normal 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>
|
||||
|
||||
68
src/views/mainpage/layout/MenuItem.vue
Normal file
68
src/views/mainpage/layout/MenuItem.vue
Normal 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>
|
||||
|
||||
81
src/views/mainpage/layout/UserInfoBar.vue
Normal file
81
src/views/mainpage/layout/UserInfoBar.vue
Normal 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
38
vite.config.js
Normal 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
14
vitest.config.js
Normal 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)),
|
||||
},
|
||||
}),
|
||||
)
|
||||
Reference in New Issue
Block a user