first commit

This commit is contained in:
2026-04-27 14:18:08 +08:00
commit 16b481e280
49 changed files with 6570 additions and 0 deletions

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

29
.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.env
.env.development
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
package-lock.json

18
.prettierrc Normal file
View File

@@ -0,0 +1,18 @@
{
"printWidth": 120,
"tabWidth": 2,
"useTabs": true,
"semi": true,
"singleQuote": true,
"jsxSingleQuote": true,
"bracketSpacing": true,
"bracketSameLine": true,
"quoteProps": "as-needed",
"trailingComma": "all",
"singleAttributePerLine": false,
"rangeStart": 0,
"requirePragma": false,
"insertPragma": false,
"proseWrap": "preserve",
"endOfLine": "lf"
}

201
LICENSE Normal file
View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

197
README.md Normal file
View File

@@ -0,0 +1,197 @@
# Vite Vue3 Template
> 一个开箱即用的 Vue3 + Vite 项目模板,采用模块化架构设计,适合快速构建中大型 Vue 应用。
---
## 技术栈
| 技术 | 版本 | 用途 |
| ------------ | ------- | ---------- |
| Vue | ^3.4.37 | 核心框架 |
| Vite | ^5.4.1 | 构建工具 |
| Vue Router | 4 | 路由管理 |
| Vuex | ^4.0.2 | 状态管理 |
| Element Plus | ^2.8.1 | UI 组件库 |
| Axios | ^1.7.7 | HTTP 请求 |
| Tailwind CSS | ^3.4.10 | 原子化 CSS |
| Less | ^4.2.0 | CSS 预处理 |
| Sass | ^1.77.8 | CSS 预处理 |
---
## 目录结构
```
vite-vue3-template/
├── public/ # 公共静态资源
├── src/
│ ├── api/ # API 接口管理
│ ├── assets/ # 静态资源
│ │ ├── images/ # 图片资源
│ │ └── styles/ # 样式文件
│ ├── components/ # 公共组件
│ ├── layouts/ # 布局组件
│ ├── plugins/ # 插件配置
│ ├── router/ # 路由配置
│ ├── store/ # Vuex 状态管理
│ ├── utils/ # 工具函数
│ ├── views/ # 页面视图
│ ├── App.vue # 根组件
│ └── main.js # 入口文件
├── index.html # HTML 模板
├── vite.config.js # Vite 配置
├── package.json # 项目配置
└── README.md # 项目说明
```
---
## 快速开始
### 1. 克隆项目
```bash
git clone <repository-url>
cd vite-vue3-template
```
### 2. 安装依赖
```bash
npm install
# 或使用 pnpm
pnpm install
# 或使用 yarn
yarn install
```
### 3. 启动开发服务器
```bash
npm run dev
```
访问 http://localhost:5173 查看项目
### 4. 生产构建
```bash
npm run build
```
构建产物将输出到 `dist` 目录
### 5. 预览构建结果
```bash
npm run preview
```
### 6. 清理项目(可选)
```bash
npm run rf
```
清除 `dist` 目录和 `node_modules`
---
## 项目特性
### 核心功能
-**模块化架构** - 清晰的目录结构,易于维护和扩展
-**组件自动导入** - 本地组件和 Element Plus 按需自动导入
-**路由模块化管理** - 按功能拆分路由,统一路由守卫
-**Vuex 持久化** - 自动持久化用户数据和配置
-**Axios 封装** - 统一的请求拦截、错误处理
-**智能代码分包** - Vue 核心库和 Element Plus 独立打包
### 开发体验
- 🚀 **快速热更新** - Vite 提供极速的开发体验
- 📦 **路径别名** - 使用 `@` 简化导入路径
- 🎨 **多样式方案** - 支持 Less、SCSS、Tailwind CSS
- 🌐 **开发代理** - 内置 `/api` 代理配置
- 📝 **工具函数** - 验证、格式化、存储等常用工具
### 生产优化
-**esbuild 压缩** - 更快的构建速度
- 🧹 **自动清理** - 生产环境移除 console 和 debugger
- 📊 **代码拆分** - CSS 和 JS 自动拆分
- 🔒 **Source Map 关闭** - 保护源码,减小包体积
---
## 配置说明
### 路径别名
在 [vite.config.js:34-40](vite.config.js#L34-L40) 中配置了路径别名,可在项目中使用:
```js
import MyComponent from '@/components/MyComponent.vue'
```
### 开发代理
开发环境下,`/api` 请求会被代理到后端服务:
```js
// vite.config.js
proxy: {
'/api': {
target: 'http://localhost:3000', // 后端地址
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
}
}
```
### Vuex 持久化
在 [src/store/index.js](src/store/index.js) 中配置了持久化,自动保存以下数据:
```js
{
user: {
userData: state.user.userData,
token: state.user.token,
},
app: {
theme: state.app.theme,
language: state.app.language,
}
}
```
---
## 添加新功能
### 添加新页面
1.`src/views/` 下创建页面组件
2.`src/router/modules/` 下创建路由模块
3.`src/router/index.js` 中引入路由模块
### 添加新 API
1.`src/api/modules/` 下创建 API 模块
2.`src/api/index.js` 中导出
### 添加新状态
1.`src/store/modules/` 下创建状态模块
2.`src/store/index.js` 中注册模块
### 添加新组件
创建在 `src/components/base/``src/components/common/` 下,组件会自动导入。
## License
[Apache-2.0 license](./LICENSE)

16
index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite Vue3 Template</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

30
package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "vite-vue3-template",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.7",
"element-plus": "^2.8.1",
"less": "^4.2.0",
"naive-ui": "^2.44.1",
"vue": "^3.4.37",
"vue-router": "4",
"vuex": "^4.0.2",
"vuex-persist": "^3.1.3"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.2",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.44",
"sass": "^1.77.8",
"tailwindcss": "^3.4.10",
"unplugin-vue-components": "^0.22.4",
"vite": "^5.4.1"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

9
src/App.vue Normal file
View File

@@ -0,0 +1,9 @@
<template>
<GlobalShell>
<router-view />
</GlobalShell>
</template>
<script setup>
import GlobalShell from '@/components/layout/GlobalShell.vue';
</script>

5
src/api/index.js Normal file
View File

@@ -0,0 +1,5 @@
/**
* API统一导出
*/
export * as userApi from './modules/user';

14
src/api/modules/user.js Normal file
View File

@@ -0,0 +1,14 @@
import request from '../request';
/**
* 用户相关接口
*/
// 用户登录
export function login(data) {
return request({
url: '/user/login',
method: 'post',
data,
});
}

69
src/api/request.js Normal file
View File

@@ -0,0 +1,69 @@
import axios from 'axios';
import { ElMessage } from 'element-plus';
// 创建axios实例
const service = axios.create({
baseURL: import.meta.env.VITE_APP_BASE_API,
timeout: 15000,
headers: {
'Content-Type': 'application/json;charset=UTF-8',
},
});
// 请求拦截器
service.interceptors.request.use(
(config) => {
// 在发送请求之前做些什么
const token = localStorage.getItem('token');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
},
(error) => {
// 对请求错误做些什么
console.error('请求错误:', error);
return Promise.reject(error);
},
);
// 响应拦截器
service.interceptors.response.use(
(response) => {
const res = response.data;
// 根据后端返回的状态码进行处理
if (res.code !== 200 && res.code !== 0) {
ElMessage({
message: res.message || '请求失败',
type: 'error',
duration: 3000,
});
// 401: Token过期
if (res.code === 401) {
// 可以在这里处理token过期的逻辑比如跳转到登录页
localStorage.removeItem('token');
window.location.href = '/login';
}
return Promise.reject(new Error(res.message || '请求失败'));
} else {
return res;
}
},
(error) => {
console.error('响应错误:', error);
// 处理HTTP状态码错误
if (error.response) {
ElMessage.error(error.response.status);
} else {
ElMessage.error('网络连接失败');
}
return Promise.reject(error);
},
);
export default service;

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,36 @@
/* 清除浮动 */
.clearfix::after {
content: '';
display: block;
clear: both;
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(0, 0, 0, 0.3);
}
::-webkit-scrollbar-track {
background-color: transparent;
}
/* 动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}

View File

@@ -0,0 +1,5 @@
:root {
--el-color-primary: #3237C8 !important;
--el-color-primary-light-3: #3237C8 !important;
--el-color-primary-dark-2: #3237C8 !important;
}

View File

@@ -0,0 +1,69 @@
/* CSS Reset - 样式重置 */
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
height: 100%;
width: 100%;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
color: #333;
background-color: #fff;
}
/* 列表样式重置 */
ul,
ol {
list-style: none;
}
/* 链接样式重置 */
a {
text-decoration: none;
color: inherit;
}
/* 图片样式 */
img {
max-width: 100%;
height: auto;
display: block;
}
/* 表格样式重置 */
table {
border-collapse: collapse;
border-spacing: 0;
}
/* 按钮样式重置 */
button,
input,
select,
textarea {
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button {
cursor: pointer;
border: none;
outline: none;
background: none;
}

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,46 @@
/* CSS 变量定义 */
:root {
/* 主题色 */
--primary-color: #409eff;
--success-color: #67c23a;
--warning-color: #e6a23c;
--danger-color: #f56c6c;
--info-color: #909399;
/* 文本颜色 */
--text-primary: #303133;
--text-regular: #606266;
--text-secondary: #909399;
--text-placeholder: #c0c4cc;
/* 边框颜色 */
--border-base: #dcdfe6;
--border-light: #e4e7ed;
--border-lighter: #ebeef5;
--border-extra-light: #f2f6fc;
/* 背景色 */
--bg-color: #ffffff;
--bg-page: #f5f7fa;
--bg-overlay: rgba(0, 0, 0, 0.5);
/* 间距 */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
/* 圆角 */
--border-radius-sm: 2px;
--border-radius-base: 4px;
--border-radius-lg: 8px;
/* 阴影 */
--box-shadow-light: 0 2px 4px rgba(0, 0, 0, 0.12);
--box-shadow-base: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
--box-shadow-dark: 0 4px 16px 0 rgba(0, 0, 0, 0.15);
/* 过渡 */
--transition-base: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}

View File

@@ -0,0 +1,22 @@
:root {
font-family: 'Microsoft YaHei UI', 'PingFang SC', 'Noto Sans SC', sans-serif;
line-height: 1.5;
font-weight: 400;
color: #16253d;
background-color: #edf2f8;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
min-width: 1280px;
min-height: 100vh;
overflow-x: auto;
background-color: #edf2f8;
}
#app {
min-height: 100vh;
}

View File

@@ -0,0 +1,24 @@
<template>
<h1 class="text-4xl">{{ msg }}</h1>
<div class="card">
<el-button type="primary" @click="count++">count is {{ count }}</el-button>
</div>
<p class="mb-5">这是一个基于 vite + vue3 的开发模板</p>
<p class="mb-5">
<a href="https://github.com/anghunk/vite-vue3-template">
https://github.com/anghunk/vite-vue3-template
</a>
</p>
<p>已经引入 vuexrouteraxioselement-plusless 等开发工具</p>
</template>
<script>
export default {
data() {
return {
count: 0,
};
},
props: ["msg"],
};
</script>

View File

@@ -0,0 +1,537 @@
<template>
<div class="global-shell">
<header class="global-shell__header">
<div class="brand-block">
<div class="brand-logo" aria-hidden="true">
<span></span>
</div>
<div class="brand-copy">
<strong class="brand-copy__title">江苏经营管理平台</strong>
</div>
</div>
<nav class="menu-strip" aria-label="主导航">
<div
v-for="item in menuItems"
:key="item.label"
class="menu-entry"
:class="{
'menu-entry--active': isActiveItem(item),
'menu-entry--children': Boolean(item.children?.length),
}"
>
<button
type="button"
class="menu-strip__item"
@click="handleMenuClick(item)"
>
{{ item.label }}
</button>
<div
v-if="item.children?.length"
class="menu-entry__dropdown"
role="menu"
:aria-label="`${item.label}子菜单`"
>
<button
v-for="child in item.children"
:key="child.label"
type="button"
class="menu-entry__child"
:class="{ 'menu-entry__child--active': isActiveItem(child) }"
@click="handleMenuClick(child)"
>
<span class="menu-entry__child-title">{{ child.label }}</span>
</button>
</div>
</div>
</nav>
<div class="action-strip">
<button
type="button"
class="ai-badge"
aria-label="AI 助手"
@click="handlePrototypeNotice"
>
<span class="ai-badge__icon">AI</span>
</button>
<button
type="button"
class="log-button"
@click="handlePrototypeNotice"
>
导出工作日志
</button>
<button
type="button"
class="icon-button"
aria-label="通知中心"
@click="handlePrototypeNotice"
>
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M12 4.5a4.5 4.5 0 0 0-4.5 4.5v2.18c0 .57-.17 1.12-.49 1.59L5.5 15.1h13l-1.5-2.33a2.94 2.94 0 0 1-.5-1.59V9A4.5 4.5 0 0 0 12 4.5Z"
/>
<path d="M9.75 18a2.25 2.25 0 0 0 4.5 0" />
</svg>
</button>
<button
type="button"
class="profile-chip"
aria-label="管理员信息"
@click="handlePrototypeNotice"
>
<span class="profile-chip__avatar"></span>
<span class="profile-chip__name">管理员</span>
</button>
</div>
</header>
<main class="global-shell__workspace">
<slot />
</main>
</div>
</template>
<script setup>
import { ElMessage } from 'element-plus';
import { useRoute, useRouter } from 'vue-router';
const route = useRoute();
const router = useRouter();
const menuItems = [
{ label: '首页', routeName: 'Home' },
{ label: '跟踪项目' },
{ label: '预审项目' },
{ label: '投标项目' },
{ label: '在建项目' },
{ label: '对接交流' },
{ label: '资料中心' },
{ label: '人员档案' },
{ label: '模板管理' },
{
label: '项目监管',
routeName: 'SupervisionPlanManagement',
activeNames: ['SupervisionPlanManagement', 'SupervisionVideoManagement'],
children: [
{
label: '监管计划',
routeName: 'SupervisionPlanManagement',
},
{
label: '监管内容',
},
{
label: '上级来访/督导',
},
{
label: '红黄牌处罚',
},
{
label: '监控视频',
routeName: 'SupervisionVideoManagement',
},
],
},
{
label: '招投标信息管理',
routeName: 'TenderProjectManagement',
activeNames: ['TenderProjectManagement', 'AwardProjectManagement', 'SectorManagement'],
children: [
{
label: '招标项目管理',
note: '管理招标金额、发布日期与详情链接',
routeName: 'TenderProjectManagement',
},
{
label: '中标项目管理',
note: '管理中标金额与中标单位信息',
routeName: 'AwardProjectManagement',
},
{
label: '板块管理',
routeName: 'SectorManagement',
},
],
},
{ label: '系统管理' },
];
const handlePrototypeNotice = () => {
ElMessage({
type: 'info',
message: '该功能正在原型完善中',
grouping: true,
});
};
const isActiveItem = (item) => {
if (item.activeNames?.includes(route.name)) {
return true;
}
return item.routeName ? route.name === item.routeName : false;
};
const handleMenuClick = (item) => {
if (item.routeName) {
router.push({ name: item.routeName });
return;
}
handlePrototypeNotice();
};
</script>
<style scoped>
.global-shell {
--shell-header-offset: 74px;
min-height: 100vh;
display: flex;
flex-direction: column;
background:
radial-gradient(circle at top left, rgba(80, 116, 255, 0.18), transparent 28%),
radial-gradient(circle at top right, rgba(29, 199, 177, 0.12), transparent 24%),
linear-gradient(180deg, #f4f7fb 0%, #edf2f8 100%);
}
.global-shell__header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
gap: 14px;
min-height: 58px;
padding: 8px 20px;
border-bottom: 1px solid rgba(22, 37, 66, 0.08);
background: rgba(8, 20, 43, 0.92);
backdrop-filter: blur(18px);
box-shadow: 0 12px 24px rgba(10, 26, 52, 0.14);
}
.brand-block {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 10px;
min-width: 206px;
}
.brand-logo {
width: 32px;
height: 32px;
border-radius: 10px;
display: grid;
place-items: center;
color: #ecf5ff;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.08em;
background:
linear-gradient(145deg, rgba(92, 133, 255, 0.95), rgba(24, 196, 185, 0.92));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.24),
0 10px 18px rgba(17, 78, 149, 0.24);
}
.brand-copy__title {
color: #f7fbff;
font-size: 16px;
font-weight: 700;
letter-spacing: 0.04em;
white-space: nowrap;
}
.menu-strip {
flex: 1;
display: flex;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
gap: 4px;
min-width: 0;
margin-left: 8px;
}
.menu-entry {
position: relative;
}
.menu-strip__item {
position: relative;
padding: 6px 9px;
border-radius: 999px;
color: rgba(224, 233, 247, 0.9);
font-size: 12px;
font-weight: 400;
letter-spacing: 0.02em;
white-space: nowrap;
transition:
transform 0.2s ease,
color 0.2s ease,
background-color 0.2s ease,
box-shadow 0.2s ease;
}
.menu-strip__item::after {
content: '';
position: absolute;
left: 9px;
right: 9px;
bottom: 4px;
height: 1px;
border-radius: 999px;
background: linear-gradient(90deg, #86a8ff, #5ef0d7);
transform: scaleX(0);
transform-origin: center;
transition: transform 0.2s ease;
}
.menu-strip__item:hover,
.menu-entry--active .menu-strip__item,
.menu-entry--children:hover .menu-strip__item {
color: #ffffff;
background: rgba(255, 255, 255, 0.08);
transform: translateY(-1px);
}
.menu-strip__item:hover::after,
.menu-entry--active .menu-strip__item::after,
.menu-entry--children:hover .menu-strip__item::after {
transform: scaleX(1);
}
.menu-entry__dropdown {
position: absolute;
top: calc(100% + 8px);
left: 0;
min-width: 280px;
padding: 10px;
border: 1px solid rgba(102, 152, 255, 0.18);
border-radius: 18px;
background:
linear-gradient(180deg, rgba(11, 24, 46, 0.96), rgba(8, 19, 37, 0.98)),
rgba(8, 19, 37, 0.96);
box-shadow:
0 24px 50px rgba(4, 11, 24, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
opacity: 0;
pointer-events: none;
transform: translateY(8px);
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.menu-entry__dropdown::before {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 100%;
height: 12px;
}
.menu-entry--children:hover .menu-entry__dropdown {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.menu-entry__child {
width: 100%;
padding: 12px 14px;
border-radius: 14px;
display: inline-flex;
align-items: center;
color: #edf4ff;
text-align: left;
transition:
background-color 0.2s ease,
transform 0.2s ease,
box-shadow 0.2s ease;
}
.menu-entry__child + .menu-entry__child {
margin-top: 6px;
}
.menu-entry__child:hover,
.menu-entry__child--active {
background: linear-gradient(135deg, rgba(92, 133, 255, 0.22), rgba(24, 196, 185, 0.16));
transform: translateY(-1px);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.menu-entry__child-title {
font-size: 13px;
font-weight: 600;
}
.action-strip {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
}
.ai-badge,
.icon-button,
.profile-chip,
.log-button {
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
transition:
transform 0.2s ease,
border-color 0.2s ease,
background-color 0.2s ease;
}
.ai-badge:hover,
.icon-button:hover,
.profile-chip:hover,
.log-button:hover {
transform: translateY(-1px);
border-color: rgba(105, 233, 219, 0.36);
background: rgba(255, 255, 255, 0.08);
}
.ai-badge,
.icon-button {
width: 30px;
height: 30px;
border-radius: 10px;
display: grid;
place-items: center;
}
.ai-badge__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
border-radius: 8px;
color: #0e223e;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.08em;
background: linear-gradient(135deg, #8fd8ff 0%, #85ffd1 100%);
}
.log-button {
height: 30px;
padding: 0 12px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #eff7ff;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.icon-button svg {
width: 14px;
height: 14px;
stroke: #ecf5ff;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.profile-chip {
height: 30px;
padding: 0 10px 0 5px;
border-radius: 999px;
display: inline-flex;
align-items: center;
gap: 6px;
color: #eff7ff;
}
.profile-chip__avatar {
width: 20px;
height: 20px;
border-radius: 50%;
display: grid;
place-items: center;
color: #0c1f39;
font-size: 10px;
font-weight: 700;
background: linear-gradient(135deg, #f2f7ff 0%, #aac6ff 100%);
}
.profile-chip__name {
font-size: 12px;
font-weight: 600;
}
.global-shell__workspace {
flex: 1;
padding: 14px 20px 20px;
}
@media (max-width: 1480px) {
.global-shell {
--shell-header-offset: 116px;
}
.global-shell__header {
align-items: flex-start;
flex-wrap: wrap;
}
.menu-strip {
order: 3;
width: 100%;
justify-content: flex-start;
}
}
@media (max-width: 768px) {
.global-shell {
--shell-header-offset: 152px;
}
.global-shell__header {
padding: 8px 12px;
gap: 10px;
}
.brand-block {
min-width: auto;
}
.brand-copy__title {
font-size: 14px;
}
.menu-entry__dropdown {
min-width: 240px;
}
.action-strip {
width: 100%;
flex-wrap: wrap;
}
.log-button {
flex: 1;
justify-content: center;
}
.global-shell__workspace {
padding: 12px;
}
}
</style>

25
src/main.js Normal file
View File

@@ -0,0 +1,25 @@
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import store from './store';
// 样式导入
import '@/assets/styles/base/reset.css';
import '@/assets/styles/base/variables.css';
import '@/assets/styles/base/common.css';
import '@/assets/styles/base/tailwind.css';
import '@/assets/styles/index.less';
// 插件
import setupPlugins from './plugins';
const app = createApp(App);
// 注册插件
setupPlugins(app);
// 注册路由和状态管理
app.use(router);
app.use(store);
app.mount('#app');

View File

@@ -0,0 +1,11 @@
/**
* Element Plus 插件配置
*/
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import '@/assets/styles/base/element-variables.scss';
export default function setupElementPlus(app) {
app.use(ElementPlus);
}

11
src/plugins/index.js Normal file
View File

@@ -0,0 +1,11 @@
/**
* 插件统一注册
*/
import setupElementPlus from './element-plus';
export default function setupPlugins(app) {
// 注册 Element Plus
setupElementPlus(app);
}

44
src/router/guards.js Normal file
View File

@@ -0,0 +1,44 @@
/**
* 全局前置守卫
*/
export function setupRouterGuards(router) {
// 全局前置守卫
router.beforeEach((to, from, next) => {
// 设置页面标题
if (to.meta.title) {
document.title = to.meta.title;
}
// 示例:权限验证
// const token = localStorage.getItem('token');
// if (!token && to.path !== '/login') {
// next('/login');
// } else {
// next();
// }
next();
});
// 全局后置钩子
router.afterEach((to, from) => {
// 可以在这里处理页面跳转后的逻辑
// 例如:埋点、页面滚动等
});
// 错误处理
router.onError((error) => {
// 检查接口返回的状态码是否为500
if (error.response && error.response.status === 500) {
router.push({
name: 'ServerError',
});
} else if (error.response && error.response.status === 404) {
router.push({
name: 'NotFound',
});
} else {
console.error('路由错误:', error);
}
});
}

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

@@ -0,0 +1,23 @@
import { createRouter, createWebHashHistory } from 'vue-router';
import { setupRouterGuards } from './guards';
import homeRoutes from './modules/home';
import userRoutes from './modules/user';
import supervisionRoutes from './modules/supervision';
import tenderingRoutes from './modules/tendering';
const routes = [
...homeRoutes,
...userRoutes,
...supervisionRoutes,
...tenderingRoutes,
];
const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL),
routes,
});
setupRouterGuards(router);
export default router;

View File

@@ -0,0 +1,15 @@
/**
* 首页路由配置
*/
export default [
{
path: '/',
name: 'Home',
component: () => import('@/views/home/index.vue'),
meta: {
title: '首页',
keepAlive: true,
},
},
];

View File

@@ -0,0 +1,18 @@
export default [
{
path: '/supervision/plans',
name: 'SupervisionPlanManagement',
component: () => import('@/views/supervision/plan/index.vue'),
meta: {
title: '监管计划',
},
},
{
path: '/supervision/videos',
name: 'SupervisionVideoManagement',
component: () => import('@/views/supervision/video/index.vue'),
meta: {
title: '监控视频',
},
},
];

View File

@@ -0,0 +1,26 @@
export default [
{
path: '/tendering/tender-projects',
name: 'TenderProjectManagement',
component: () => import('@/views/tendering/tender/index.vue'),
meta: {
title: '招标项目管理',
},
},
{
path: '/tendering/award-projects',
name: 'AwardProjectManagement',
component: () => import('@/views/tendering/award/index.vue'),
meta: {
title: '中标项目管理',
},
},
{
path: '/tendering/sector-management',
name: 'SectorManagement',
component: () => import('@/views/tendering/sector/index.vue'),
meta: {
title: '板块管理',
},
},
];

View File

@@ -0,0 +1,14 @@
/**
* 用户相关路由配置
*/
export default [
{
path: '/user',
name: 'User',
component: () => import('@/views/user/index.vue'),
meta: {
title: '用户中心',
},
},
];

19
src/store/getters.js Normal file
View File

@@ -0,0 +1,19 @@
/**
* 全局 getters
*/
const getters = {
// 用户模块
userData: (state) => state.user.userData,
token: (state) => state.user.token,
userInfo: (state) => state.user.userInfo,
isLoggedIn: (state) => state.user.isLoggedIn,
// 应用模块
theme: (state) => state.app.theme,
language: (state) => state.app.language,
collapsed: (state) => state.app.collapsed,
loading: (state) => state.app.loading,
};
export default getters;

35
src/store/index.js Normal file
View File

@@ -0,0 +1,35 @@
import { createStore } from 'vuex';
import VuexPersist from 'vuex-persist';
import getters from './getters';
// 导入模块
import user from './modules/user';
import app from './modules/app';
// vuex 持久化配置
const vuexPersist = new VuexPersist({
key: 'vuex',
storage: localStorage,
reducer: (state) => ({
user: {
userData: state.user.userData,
token: state.user.token,
},
app: {
theme: state.app.theme,
language: state.app.language,
},
}),
});
// 创建 store 实例
const store = createStore({
modules: {
user,
app,
},
getters,
plugins: [vuexPersist.plugin],
});
export default store;

57
src/store/modules/app.js Normal file
View File

@@ -0,0 +1,57 @@
/**
* 应用模块状态管理
*/
const state = {
theme: localStorage.getItem('theme') || 'light',
language: localStorage.getItem('language') || 'zh-CN',
collapsed: false, // 侧边栏折叠状态
loading: false, // 全局加载状态
};
const mutations = {
SET_THEME(state, theme) {
state.theme = theme;
localStorage.setItem('theme', theme);
},
SET_LANGUAGE(state, language) {
state.language = language;
localStorage.setItem('language', language);
},
SET_COLLAPSED(state, collapsed) {
state.collapsed = collapsed;
},
SET_LOADING(state, loading) {
state.loading = loading;
},
};
const actions = {
setTheme({ commit }, theme) {
commit('SET_THEME', theme);
},
setLanguage({ commit }, language) {
commit('SET_LANGUAGE', language);
},
toggleCollapsed({ commit, state }) {
commit('SET_COLLAPSED', !state.collapsed);
},
setLoading({ commit }, loading) {
commit('SET_LOADING', loading);
},
};
const getters = {
theme: (state) => state.theme,
language: (state) => state.language,
collapsed: (state) => state.collapsed,
loading: (state) => state.loading,
};
export default {
namespaced: true,
state,
mutations,
actions,
getters,
};

66
src/store/modules/user.js Normal file
View File

@@ -0,0 +1,66 @@
/**
* 用户模块状态管理
*/
const state = {
userData: {},
token: localStorage.getItem('token') || '',
userInfo: null,
};
const mutations = {
SET_USER_DATA(state, data) {
state.userData = data;
},
SET_TOKEN(state, token) {
state.token = token;
if (token) {
localStorage.setItem('token', token);
} else {
localStorage.removeItem('token');
}
},
SET_USER_INFO(state, userInfo) {
state.userInfo = userInfo;
},
CLEAR_USER_DATA(state) {
state.userData = {};
state.token = '';
state.userInfo = null;
localStorage.removeItem('token');
},
};
const actions = {
// 设置用户数据
setUserData({ commit }, data) {
commit('SET_USER_DATA', data);
},
// 设置token
setToken({ commit }, token) {
commit('SET_TOKEN', token);
},
// 设置用户信息
setUserInfo({ commit }, userInfo) {
commit('SET_USER_INFO', userInfo);
},
// 清除用户数据
clearUserData({ commit }) {
commit('CLEAR_USER_DATA');
},
};
const getters = {
userData: (state) => state.userData,
token: (state) => state.token,
userInfo: (state) => state.userInfo,
isLoggedIn: (state) => !!state.token,
};
export default {
namespaced: true,
state,
mutations,
actions,
getters,
};

70
src/utils/format.js Normal file
View File

@@ -0,0 +1,70 @@
/**
* 格式化工具函数
*/
/**
* 格式化日期时间
*/
export function formatDate(date, format = 'YYYY-MM-DD HH:mm:ss') {
if (!date) return '';
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const hour = String(d.getHours()).padStart(2, '0');
const minute = String(d.getMinutes()).padStart(2, '0');
const second = String(d.getSeconds()).padStart(2, '0');
return format
.replace('YYYY', year)
.replace('MM', month)
.replace('DD', day)
.replace('HH', hour)
.replace('mm', minute)
.replace('ss', second);
}
/**
* 格式化金额(千分位)
*/
export function formatMoney(money, decimals = 2) {
if (!money) return '0.00';
const num = parseFloat(money);
return num.toFixed(decimals).replace(/\d(?=(\d{3})+\.)/g, '$&,');
}
/**
* 格式化文件大小
*/
export function formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i];
}
/**
* 手机号脱敏
*/
export function formatPhone(phone) {
if (!phone) return '';
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
}
/**
* 身份证号脱敏
*/
export function formatIdCard(idCard) {
if (!idCard) return '';
return idCard.replace(/(\d{6})\d{8}(\d{4})/, '$1********$2');
}
/**
* 银行卡号脱敏
*/
export function formatBankCard(cardNo) {
if (!cardNo) return '';
return cardNo.replace(/(\d{4})\d+(\d{4})/, '$1 **** **** $2');
}

8
src/utils/index.js Normal file
View File

@@ -0,0 +1,8 @@
/**
* 工具函数统一导出
*/
export { default as storage } from './storage';
export * from './auth';
export * from './validate';
export * from './format';

88
src/utils/storage.js Normal file
View File

@@ -0,0 +1,88 @@
/**
* 本地存储工具
*/
const storage = {
/**
* 设置localStorage
*/
setLocalStorage(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
console.error('localStorage set error:', error);
}
},
/**
* 获取localStorage
*/
getLocalStorage(key) {
try {
const value = localStorage.getItem(key);
return value ? JSON.parse(value) : null;
} catch (error) {
console.error('localStorage get error:', error);
return null;
}
},
/**
* 删除localStorage
*/
removeLocalStorage(key) {
try {
localStorage.removeItem(key);
} catch (error) {
console.error('localStorage remove error:', error);
}
},
/**
* 清空localStorage
*/
clearLocalStorage() {
try {
localStorage.clear();
} catch (error) {
console.error('localStorage clear error:', error);
}
},
/**
* 设置sessionStorage
*/
setSession(key, value) {
try {
sessionStorage.setItem(key, JSON.stringify(value));
} catch (error) {
console.error('sessionStorage set error:', error);
}
},
/**
* 获取sessionStorage
*/
getSession(key) {
try {
const value = sessionStorage.getItem(key);
return value ? JSON.parse(value) : null;
} catch (error) {
console.error('sessionStorage get error:', error);
return null;
}
},
/**
* 删除sessionStorage
*/
removeSession(key) {
try {
sessionStorage.removeItem(key);
} catch (error) {
console.error('sessionStorage remove error:', error);
}
},
};
export default storage;

60
src/utils/validate.js Normal file
View File

@@ -0,0 +1,60 @@
/**
* 验证工具函数
*/
/**
* 验证邮箱
*/
export function isEmail(email) {
const reg = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6}$/;
return reg.test(email);
}
/**
* 验证手机号
*/
export function isPhone(phone) {
const reg = /^1[3-9]\d{9}$/;
return reg.test(phone);
}
/**
* 验证身份证号
*/
export function isIdCard(idCard) {
const reg = /(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)/;
return reg.test(idCard);
}
/**
* 验证URL
*/
export function isUrl(url) {
const reg =
/^(https?|ftp):\/\/([a-zA-Z0-9.-]+(:[a-zA-Z0-9.&%$-]+)*@)*((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}|([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+\.(com|edu|gov|int|mil|net|org|biz|arpa|info|name|pro|aero|coop|museum|[a-zA-Z]{2}))(:[0-9]+)*(\/($|[a-zA-Z0-9.,?'\\+&%$#=~_-]+))*$/;
return reg.test(url);
}
/**
* 验证密码强度至少包含数字和字母长度6-20
*/
export function isStrongPassword(password) {
const reg = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{6,20}$/;
return reg.test(password);
}
/**
* 验证中文
*/
export function isChinese(str) {
const reg = /^[\u4e00-\u9fa5]+$/;
return reg.test(str);
}
/**
* 验证数字
*/
export function isNumber(num) {
const reg = /^[0-9]+$/;
return reg.test(num);
}

23
src/views/home/index.vue Normal file
View File

@@ -0,0 +1,23 @@
<template>
<section class="home-empty-panel" aria-label="首页内容区"></section>
</template>
<style scoped>
.home-empty-panel {
min-height: max(420px, calc(100vh - 122px));
border: 1px dashed rgba(23, 41, 74, 0.08);
border-radius: 28px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.7), rgba(246, 249, 253, 0.96)),
repeating-linear-gradient(
90deg,
rgba(32, 74, 135, 0.025) 0,
rgba(32, 74, 135, 0.025) 1px,
transparent 1px,
transparent 120px
);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.92),
0 20px 40px rgba(26, 54, 93, 0.04);
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,191 @@
<template>
<ProjectCrudPanel
title="中标项目管理"
storage-key="admin-pc:award-projects"
date-range-prop="awardDate"
:columns="columns"
:form-fields="formFields"
:initial-records="initialRecords"
/>
</template>
<script setup>
import ProjectCrudPanel from '@/views/tendering/components/ProjectCrudPanel.vue';
const columns = [
{ prop: 'projectCode', label: '项目编号', minWidth: 150 },
{ prop: 'projectName', label: '项目名称', minWidth: 220 },
{ prop: 'sectionCode', label: '标段编号', minWidth: 150 },
{ prop: 'sectionName', label: '标段名称', minWidth: 220 },
{ prop: 'constructionCompany', label: '建设单位名称', minWidth: 220 },
{ prop: 'awardCompany', label: '中标人名称', minWidth: 220 },
{ prop: 'awardAmount', label: '中标价', minWidth: 160, type: 'currency' },
{ prop: 'awardDate', label: '中标时间', minWidth: 140 },
{ prop: 'detailLink', label: '详情链接', minWidth: 96, width: 96, type: 'link' },
];
const formFields = [
{ prop: 'projectCode', label: '项目编号', type: 'text' },
{ prop: 'projectName', label: '项目名称', type: 'text' },
{ prop: 'sectionCode', label: '标段编号', type: 'text' },
{ prop: 'sectionName', label: '标段名称', type: 'text' },
{ prop: 'constructionCompany', label: '建设单位名称', type: 'text' },
{ prop: 'awardCompany', label: '中标人名称', type: 'text' },
{ prop: 'awardAmount', label: '中标价', type: 'amount' },
{ prop: 'awardDate', label: '中标时间', type: 'date' },
{ prop: 'detailLink', label: '详情链接', type: 'url' },
];
const initialRecords = [
{
id: 'award_1',
projectCode: 'AW-2026-001',
projectName: '南京江北新材料园综合配套项目',
sectionCode: 'BD-01',
sectionName: '园区道路及附属配套工程一标段',
constructionCompany: '南京江北新区建设投资集团有限公司',
awardCompany: '江苏城建发展有限公司',
awardAmount: 17980000,
awardDate: '2026-04-22',
detailLink: 'https://example.com/award/1',
provinceCity: '江苏省南京市',
sectorType: '基础设施',
projectType: '工程类',
},
{
id: 'award_2',
projectCode: 'AW-2026-002',
projectName: '苏州产业园智慧运维升级项目',
sectionCode: 'BD-02',
sectionName: '园区智慧运维平台建设标段',
constructionCompany: '苏州高新科技产业发展有限公司',
awardCompany: '苏州云控科技股份有限公司',
awardAmount: 8860000,
awardDate: '2026-04-20',
detailLink: 'https://example.com/award/2',
provinceCity: '江苏省苏州市',
sectorType: '智慧园区',
projectType: '服务类',
},
{
id: 'award_3',
projectCode: 'AW-2026-003',
projectName: '徐州物流枢纽能源系统优化项目',
sectionCode: 'BD-03',
sectionName: '能源系统设备采购及调试标段',
constructionCompany: '徐州陆港建设管理有限公司',
awardCompany: '华东机电工程集团有限公司',
awardAmount: 13250000,
awardDate: '2026-04-19',
detailLink: 'https://example.com/award/3',
provinceCity: '江苏省徐州市',
sectorType: '能源环保',
projectType: '采购类',
},
{
id: 'award_4',
projectCode: 'AW-2026-004',
projectName: '常州高新区智慧安防平台建设项目',
sectionCode: 'BD-04',
sectionName: '智慧安防平台软硬件集成标段',
constructionCompany: '常州高新区数字城市建设有限公司',
awardCompany: '江苏智联数字科技有限公司',
awardAmount: 5980000,
awardDate: '2026-04-17',
detailLink: 'https://example.com/award/4',
provinceCity: '江苏省常州市',
sectorType: '智慧园区',
projectType: '采购类',
},
{
id: 'award_5',
projectCode: 'AW-2026-005',
projectName: '南通滨海新区公共服务中心运营项目',
sectionCode: 'BD-05',
sectionName: '公共服务中心运营管理标段',
constructionCompany: '南通滨海新区开发建设有限公司',
awardCompany: '南通城服运营管理有限公司',
awardAmount: 11360000,
awardDate: '2026-04-16',
detailLink: 'https://example.com/award/5',
provinceCity: '江苏省南通市',
sectorType: '公共服务',
projectType: '服务类',
},
{
id: 'award_6',
projectCode: 'AW-2026-006',
projectName: '扬州智能制造产业基地设备采购项目',
sectionCode: 'BD-06',
sectionName: '生产线设备采购二标段',
constructionCompany: '扬州经开产业投资有限公司',
awardCompany: '华东智能装备供应链有限公司',
awardAmount: 14750000,
awardDate: '2026-04-14',
detailLink: 'https://example.com/award/6',
provinceCity: '江苏省扬州市',
sectorType: '智能制造',
projectType: '采购类',
},
{
id: 'award_7',
projectCode: 'AW-2026-007',
projectName: '镇江城市更新配套路桥施工项目',
sectionCode: 'BD-07',
sectionName: '主线道路及桥梁施工标段',
constructionCompany: '镇江市城市更新建设发展有限公司',
awardCompany: '镇江建工集团有限公司',
awardAmount: 26880000,
awardDate: '2026-04-12',
detailLink: 'https://example.com/award/7',
provinceCity: '江苏省镇江市',
sectorType: '基础设施',
projectType: '工程类',
},
{
id: 'award_8',
projectCode: 'AW-2026-008',
projectName: '盐城零碳园区综合咨询服务项目',
sectionCode: 'BD-08',
sectionName: '零碳园区综合咨询服务标段',
constructionCompany: '盐城绿色低碳产业发展有限公司',
awardCompany: '江苏绿源咨询顾问有限公司',
awardAmount: 3620000,
awardDate: '2026-04-10',
detailLink: 'https://example.com/award/8',
provinceCity: '江苏省盐城市',
sectorType: '能源环保',
projectType: '服务类',
},
{
id: 'award_9',
projectCode: 'AW-2026-009',
projectName: '连云港港区仓储自动化改造项目',
sectionCode: 'BD-09',
sectionName: '自动化仓储改造施工标段',
constructionCompany: '连云港港口建设运营有限公司',
awardCompany: '连云港港机自动化工程有限公司',
awardAmount: 8450000,
awardDate: '2026-04-08',
detailLink: 'https://example.com/award/9',
provinceCity: '江苏省连云港市',
sectorType: '物流仓储',
projectType: '其他',
},
{
id: 'award_10',
projectCode: 'AW-2026-010',
projectName: '宿迁文旅综合体机电安装工程',
sectionCode: 'BD-10',
sectionName: '机电安装施工总承包标段',
constructionCompany: '宿迁文旅产业发展集团有限公司',
awardCompany: '江苏弘远机电安装有限公司',
awardAmount: 21400000,
awardDate: '2026-04-06',
detailLink: 'https://example.com/award/10',
provinceCity: '江苏省宿迁市',
sectorType: '文旅配套',
projectType: '工程类',
},
];
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,441 @@
<template>
<section class="sector-page">
<div class="sector-card">
<div class="sector-toolbar">
<h1 class="sector-toolbar__title">板块管理</h1>
<el-button type="primary" @click="openCreateRootDialog">新增一级板块</el-button>
</div>
<el-empty
v-if="!treeData.length"
description="暂无板块数据"
/>
<el-tree
v-else
:data="treeData"
:props="treeProps"
node-key="id"
default-expand-all
:expand-on-click-node="false"
class="sector-tree"
>
<template #default="{ data }">
<div class="sector-node">
<div class="sector-node__main">
<span class="sector-node__label">{{ data.label }}</span>
</div>
<div class="sector-node__actions">
<button
type="button"
class="sector-node__action"
@click="openCreateChildDialog(data)"
>
新增子级
</button>
<button
type="button"
class="sector-node__action"
@click="openEditDialog(data)"
>
编辑
</button>
<button
type="button"
class="sector-node__action sector-node__action--danger"
@click="deleteNode(data)"
>
删除
</button>
</div>
</div>
</template>
</el-tree>
</div>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="520px"
destroy-on-close
>
<el-form label-width="92px">
<el-form-item
v-if="currentAction.type === 'create-child'"
label="上级板块"
>
<el-input :model-value="currentAction.parentLabel" disabled />
</el-form-item>
<el-form-item label="板块名称" required>
<el-input
v-model="formModel.label"
maxlength="30"
placeholder="请输入板块名称"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">保存</el-button>
</div>
</template>
</el-dialog>
</section>
</template>
<script setup>
import { computed, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import storage from '@/utils/storage';
const STORAGE_KEY = 'admin-pc:sector-tree';
const treeProps = {
children: 'children',
label: 'label',
};
const defaultTreeData = [
{
id: 'infra',
label: '基础设施',
children: [
{
id: 'infra-road',
label: '道路工程',
children: [],
},
{
id: 'infra-mep',
label: '机电安装',
children: [],
},
],
},
{
id: 'smart-park',
label: '智慧园区',
children: [
{
id: 'smart-park-security',
label: '智慧安防',
children: [],
},
{
id: 'smart-park-operation',
label: '智慧运维',
children: [],
},
],
},
{
id: 'energy',
label: '能源环保',
children: [
{
id: 'energy-station',
label: '能源站',
children: [],
},
{
id: 'energy-consulting',
label: '绿色咨询',
children: [],
},
],
},
];
const normalizeTree = (nodes) =>
(Array.isArray(nodes) ? nodes : []).map((node) => ({
id: node.id,
label: node.label || '',
children: normalizeTree(node.children),
}));
const cloneTree = (nodes) =>
nodes.map((node) => ({
...node,
children: cloneTree(node.children || []),
}));
const treeData = ref(
normalizeTree(storage.getLocalStorage(STORAGE_KEY) || defaultTreeData),
);
const dialogVisible = ref(false);
const formModel = reactive({
label: '',
});
const currentAction = reactive({
type: 'create-root',
targetId: '',
parentLabel: '',
});
const dialogTitle = computed(() => {
if (currentAction.type === 'create-child') {
return '新增子级板块';
}
if (currentAction.type === 'edit') {
return '编辑板块';
}
return '新增一级板块';
});
const persistTree = () => {
storage.setLocalStorage(STORAGE_KEY, treeData.value);
};
const generateId = () => `sector_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const flattenTree = (nodes) =>
nodes.flatMap((node) => [node, ...flattenTree(node.children || [])]);
const findNode = (nodes, id) => {
for (const node of nodes) {
if (node.id === id) {
return node;
}
const matched = findNode(node.children || [], id);
if (matched) {
return matched;
}
}
return null;
};
const removeNode = (nodes, id) =>
nodes
.filter((node) => node.id !== id)
.map((node) => ({
...node,
children: removeNode(node.children || [], id),
}));
const resetForm = () => {
formModel.label = '';
currentAction.type = 'create-root';
currentAction.targetId = '';
currentAction.parentLabel = '';
};
const openCreateRootDialog = () => {
resetForm();
currentAction.type = 'create-root';
dialogVisible.value = true;
};
const openCreateChildDialog = (node) => {
resetForm();
currentAction.type = 'create-child';
currentAction.targetId = node.id;
currentAction.parentLabel = node.label;
dialogVisible.value = true;
};
const openEditDialog = (node) => {
resetForm();
currentAction.type = 'edit';
currentAction.targetId = node.id;
formModel.label = node.label;
dialogVisible.value = true;
};
const submitForm = () => {
const label = formModel.label.trim();
if (!label) {
ElMessage.warning('请输入板块名称');
return;
}
const duplicated = flattenTree(treeData.value).find(
(node) => node.label === label && node.id !== currentAction.targetId,
);
if (duplicated) {
ElMessage.warning('板块名称已存在');
return;
}
const nextTree = cloneTree(treeData.value);
if (currentAction.type === 'create-root') {
nextTree.push({
id: generateId(),
label,
children: [],
});
} else if (currentAction.type === 'create-child') {
const parentNode = findNode(nextTree, currentAction.targetId);
if (!parentNode) {
ElMessage.error('未找到上级板块');
return;
}
parentNode.children.push({
id: generateId(),
label,
children: [],
});
} else if (currentAction.type === 'edit') {
const targetNode = findNode(nextTree, currentAction.targetId);
if (!targetNode) {
ElMessage.error('未找到待编辑板块');
return;
}
targetNode.label = label;
}
treeData.value = nextTree;
persistTree();
dialogVisible.value = false;
ElMessage.success(
currentAction.type === 'edit' ? '板块已更新' : '板块已保存',
);
};
const deleteNode = async (node) => {
const hasChildren = Boolean(node.children?.length);
const message = hasChildren
? `确认删除“${node.label}”及其下级板块吗?`
: `确认删除“${node.label}”吗?`;
try {
await ElMessageBox.confirm(message, '删除确认', {
type: 'warning',
});
} catch {
return;
}
treeData.value = removeNode(treeData.value, node.id);
persistTree();
ElMessage.success('板块已删除');
};
</script>
<style scoped>
.sector-page {
display: flex;
flex-direction: column;
}
.sector-card {
padding: 20px;
border: 1px solid rgba(112, 144, 196, 0.12);
border-radius: 2px;
background: rgba(255, 255, 255, 0.92);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.92),
0 18px 36px rgba(26, 54, 93, 0.05);
}
.sector-toolbar {
margin-bottom: 18px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.sector-toolbar__title {
color: #142949;
font-size: 24px;
font-weight: 700;
}
.sector-tree {
margin: -6px;
border: 1px solid #d9e3f0;
background: #ffffff;
}
.sector-node {
width: 100%;
min-height: 46px;
padding: 8px 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.sector-node__main {
min-width: 0;
display: flex;
align-items: center;
}
.sector-node__label {
color: #1b2d44;
font-size: 14px;
font-weight: 600;
}
.sector-node__actions {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.sector-node__action {
color: #1d4ed8;
font-size: 13px;
}
.sector-node__action--danger {
color: #da4f60;
}
:deep(.sector-tree .el-tree-node__content) {
min-height: 52px;
border-bottom: 1px solid #e5ebf3;
border-radius: 2px;
box-sizing: border-box;
}
:deep(.sector-tree > .el-tree-node > .el-tree-node__content) {
border-left: none;
border-right: none;
}
:deep(.sector-tree .el-tree-node:last-child > .el-tree-node__content) {
border-bottom: none;
}
:deep(.sector-tree .el-tree-node__content:hover) {
background: rgba(37, 99, 235, 0.06);
}
@media (max-width: 768px) {
.sector-toolbar {
align-items: stretch;
flex-direction: column;
}
.sector-node {
align-items: flex-start;
flex-direction: column;
}
.sector-node__main,
.sector-node__actions {
width: 100%;
flex-wrap: wrap;
}
}
</style>

View File

@@ -0,0 +1,158 @@
<template>
<ProjectCrudPanel
title="招标项目管理"
storage-key="admin-pc:tender-projects"
:enable-conversion-action="true"
:record-defaults="{ conversionStatus: '未转化' }"
date-range-prop="publishedAt"
amount-range-prop="tenderAmount"
amount-range-unit="万元"
:amount-scale="10000"
:columns="columns"
:form-fields="formFields"
:initial-records="initialRecords"
/>
</template>
<script setup>
import ProjectCrudPanel from '@/views/tendering/components/ProjectCrudPanel.vue';
const columns = [
{ prop: 'projectName', label: '项目名称', minWidth: 190 },
{ prop: 'provinceCity', label: '所属省市', minWidth: 128 },
{ prop: 'sectorType', label: '板块类型', minWidth: 112 },
{ prop: 'projectType', label: '项目类型', minWidth: 100 },
{ prop: 'tenderAmount', label: '项目金额', minWidth: 128, type: 'wanAmount' },
{ prop: 'conversionStatus', label: '是否转化', minWidth: 100 },
{ prop: 'publishedAt', label: '发布日期', minWidth: 118 },
{ prop: 'detailLink', label: '详情链接', minWidth: 96, width: 96, type: 'link' },
];
const formFields = [
{ prop: 'projectName', label: '项目名称', type: 'text' },
{ prop: 'provinceCity', label: '所属省市', type: 'select' },
{ prop: 'sectorType', label: '板块类型', type: 'select' },
{
prop: 'projectType',
label: '项目类型',
type: 'select',
options: ['服务类', '工程类', '采购类', '其他'],
},
{ prop: 'tenderAmount', label: '项目金额', type: 'amount' },
{ prop: 'publishedAt', label: '发布日期', type: 'date' },
{ prop: 'detailLink', label: '详情链接', type: 'url' },
];
const initialRecords = [
{
id: 'tender_1',
projectName: '南京江北新材料园综合配套项目',
provinceCity: '江苏省南京市',
sectorType: '基础设施',
projectType: '工程类',
tenderAmount: 18600000,
conversionStatus: '已转化',
publishedAt: '2026-04-20',
detailLink: 'https://example.com/tender/1',
},
{
id: 'tender_2',
projectName: '苏州产业园智慧运维升级项目',
provinceCity: '江苏省苏州市',
sectorType: '智慧园区',
projectType: '服务类',
tenderAmount: 9200000,
conversionStatus: '未转化',
publishedAt: '2026-04-18',
detailLink: 'https://example.com/tender/2',
},
{
id: 'tender_3',
projectName: '无锡区域能源站扩容工程',
provinceCity: '江苏省无锡市',
sectorType: '能源环保',
projectType: '工程类',
tenderAmount: 25400000,
conversionStatus: '已转化',
publishedAt: '2026-04-14',
detailLink: 'https://example.com/tender/3',
},
{
id: 'tender_4',
projectName: '常州高新区智慧安防平台建设项目',
provinceCity: '江苏省常州市',
sectorType: '智慧园区',
projectType: '采购类',
tenderAmount: 6400000,
conversionStatus: '未转化',
publishedAt: '2026-04-12',
detailLink: 'https://example.com/tender/4',
},
{
id: 'tender_5',
projectName: '南通滨海新区公共服务中心运营项目',
provinceCity: '江苏省南通市',
sectorType: '公共服务',
projectType: '服务类',
tenderAmount: 11800000,
conversionStatus: '已转化',
publishedAt: '2026-04-11',
detailLink: 'https://example.com/tender/5',
},
{
id: 'tender_6',
projectName: '扬州智能制造产业基地设备采购项目',
provinceCity: '江苏省扬州市',
sectorType: '智能制造',
projectType: '采购类',
tenderAmount: 15200000,
conversionStatus: '未转化',
publishedAt: '2026-04-09',
detailLink: 'https://example.com/tender/6',
},
{
id: 'tender_7',
projectName: '镇江城市更新配套道路施工项目',
provinceCity: '江苏省镇江市',
sectorType: '基础设施',
projectType: '工程类',
tenderAmount: 27600000,
conversionStatus: '已转化',
publishedAt: '2026-04-08',
detailLink: 'https://example.com/tender/7',
},
{
id: 'tender_8',
projectName: '盐城零碳园区综合咨询服务项目',
provinceCity: '江苏省盐城市',
sectorType: '能源环保',
projectType: '服务类',
tenderAmount: 3850000,
conversionStatus: '未转化',
publishedAt: '2026-04-06',
detailLink: 'https://example.com/tender/8',
},
{
id: 'tender_9',
projectName: '连云港港区仓储自动化改造项目',
provinceCity: '江苏省连云港市',
sectorType: '物流仓储',
projectType: '其他',
tenderAmount: 8900000,
conversionStatus: '未转化',
publishedAt: '2026-04-05',
detailLink: 'https://example.com/tender/9',
},
{
id: 'tender_10',
projectName: '宿迁文旅综合体机电安装工程',
provinceCity: '江苏省宿迁市',
sectorType: '文旅配套',
projectType: '工程类',
tenderAmount: 22100000,
conversionStatus: '已转化',
publishedAt: '2026-04-03',
detailLink: 'https://example.com/tender/10',
},
];
</script>

22
src/views/user/index.vue Normal file
View File

@@ -0,0 +1,22 @@
<template>
<div class="user-container">
用户中心
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useStore } from 'vuex';
const store = useStore();
onMounted(() => {
console.log('用户中心页面加载完成');
});
</script>
<style scoped>
.user-container {
padding: 20px;
}
</style>

8
tailwind.config.js Normal file
View File

@@ -0,0 +1,8 @@
// tailwind.config.js
export default {
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
};

72
vite.config.js Normal file
View File

@@ -0,0 +1,72 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { fileURLToPath, URL } from 'node:url';
import Components from 'unplugin-vue-components/vite';
import { ElementPlusResolver, NaiveUiResolver } from 'unplugin-vue-components/resolvers';
export default defineConfig(() => {
return {
base: './',
plugins: [
vue(),
Components({
resolvers: [
ElementPlusResolver(),
NaiveUiResolver(),
],
dirs: ['src/components'],
dts: false,
}),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
extensions: ['.js', '.vue', '.json', '.ts'],
},
server: {
host: '0.0.0.0',
port: 9527,
open: false,
},
build: {
outDir: 'dist',
assetsDir: 'assets',
assetsInlineLimit: 4096,
cssCodeSplit: true,
sourcemap: false,
chunkSizeWarningLimit: 2000,
rollupOptions: {
output: {
manualChunks: {
vue: ['vue', 'vue-router', 'vuex'],
'element-plus': ['element-plus'],
},
chunkFileNames: 'js/[name]-[hash].js',
entryFileNames: 'js/[name]-[hash].js',
assetFileNames: '[ext]/[name]-[hash].[ext]',
},
},
minify: 'esbuild',
esbuildOptions: {
drop: ['console', 'debugger'],
},
},
css: {
preprocessorOptions: {
scss: {
api: 'modern-compiler',
},
},
},
optimizeDeps: {
include: ['vue', 'vue-router', 'vuex', 'axios', 'element-plus/es'],
},
};
});