Merge branch 'master' into master
This commit is contained in:
@@ -1,13 +1,15 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
// 继承推荐规范配置
|
|
||||||
extends: [
|
extends: [
|
||||||
"stylelint-config-standard",
|
"stylelint-config-recommended",
|
||||||
"stylelint-config-recommended-scss",
|
"stylelint-config-recommended-scss",
|
||||||
"stylelint-config-recommended-vue/scss",
|
"stylelint-config-recommended-vue/scss",
|
||||||
"stylelint-config-html/vue",
|
"stylelint-config-html/vue",
|
||||||
"stylelint-config-recess-order",
|
"stylelint-config-recess-order",
|
||||||
],
|
],
|
||||||
// 指定不同文件对应的解析器
|
|
||||||
|
plugins: [
|
||||||
|
"stylelint-prettier", // 统一代码风格,格式冲突时以 Prettier 规则为准
|
||||||
|
],
|
||||||
overrides: [
|
overrides: [
|
||||||
{
|
{
|
||||||
files: ["**/*.{vue,html}"],
|
files: ["**/*.{vue,html}"],
|
||||||
@@ -18,29 +20,18 @@ module.exports = {
|
|||||||
customSyntax: "postcss-scss",
|
customSyntax: "postcss-scss",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
// 自定义规则
|
|
||||||
rules: {
|
rules: {
|
||||||
"import-notation": "string", // 指定导入CSS文件的方式("string"|"url")
|
"prettier/prettier": true, // 强制执行 Prettier 格式化规则(需配合 .prettierrc 配置文件)
|
||||||
"selector-class-pattern": null, // 选择器类名命名规则
|
"no-empty-source": null, // 允许空的样式文件
|
||||||
"custom-property-pattern": null, // 自定义属性命名规则
|
"declaration-property-value-no-unknown": null, // 允许非常规数值格式 ,如 height: calc(100% - 50)
|
||||||
"keyframes-name-pattern": null, // 动画帧节点样式命名规则
|
// 允许使用未知伪类
|
||||||
"no-descending-specificity": null, // 允许无降序特异性
|
|
||||||
"no-empty-source": null, // 允许空样式
|
|
||||||
// 允许 global 、export 、deep伪类
|
|
||||||
"selector-pseudo-class-no-unknown": [
|
"selector-pseudo-class-no-unknown": [
|
||||||
true,
|
true,
|
||||||
{
|
{
|
||||||
ignorePseudoClasses: ["global", "export", "deep"],
|
ignorePseudoClasses: ["global", "export", "deep"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
// 允许未知属性
|
// 允许使用未知伪元素
|
||||||
"property-no-unknown": [
|
|
||||||
true,
|
|
||||||
{
|
|
||||||
ignoreProperties: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
// 允许未知规则
|
|
||||||
"at-rule-no-unknown": [
|
"at-rule-no-unknown": [
|
||||||
true,
|
true,
|
||||||
{
|
{
|
||||||
|
|||||||
78
README.md
78
README.md
@@ -3,8 +3,8 @@
|
|||||||
<h1>vue3-element-admin</h1>
|
<h1>vue3-element-admin</h1>
|
||||||
|
|
||||||
<img src="https://img.shields.io/badge/Vue-3.5.13-brightgreen.svg"/>
|
<img src="https://img.shields.io/badge/Vue-3.5.13-brightgreen.svg"/>
|
||||||
<img src="https://img.shields.io/badge/Vite-6.0.5-green.svg"/>
|
<img src="https://img.shields.io/badge/Vite-6.1.0-green.svg"/>
|
||||||
<img src="https://img.shields.io/badge/Element Plus-2.9.1-blue.svg"/>
|
<img src="https://img.shields.io/badge/Element Plus-2.9.4-blue.svg"/>
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green.svg"/>
|
<img src="https://img.shields.io/badge/license-MIT-green.svg"/>
|
||||||
<a href="https://gitee.com/youlaiorg" target="_blank">
|
<a href="https://gitee.com/youlaiorg" target="_blank">
|
||||||
<img src="https://img.shields.io/badge/Author-有来开源组织-orange.svg"/>
|
<img src="https://img.shields.io/badge/Author-有来开源组织-orange.svg"/>
|
||||||
@@ -26,37 +26,32 @@
|
|||||||
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<a target="_blank" href="http://vue3.youlai.tech">🔍 在线预览</a> | <a target="_blank" href="https://juejin.cn/post/7228990409909108793">📖 阅读文档</a> | <a href="./README.en-US.md">🌐English
|
<a target="_blank" href="https://vue.youlai.tech">🖥️ 在线预览</a> | <a target="_blank" href="https://juejin.cn/post/7228990409909108793">📑 阅读文档</a> | <a href="./README.en-US.md">💬 English
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 项目简介
|
## 项目简介
|
||||||
|
|
||||||
[vue3-element-admin](https://gitcode.com/youlai/vue3-element-admin) 是基于 Vue3 + Vite5+ TypeScript5 + Element-Plus + Pinia 等主流技术栈构建的免费开源的中后台管理的前端模板(配套[Java 后端源码](https://gitee.com/youlaiorg/youlai-boot))。
|
[vue3-element-admin](https://gitcode.com/youlai/vue3-element-admin) 基于 Vue3、Vite、TypeScript 和 Element-Plus 搭建的极简开箱即用企业级后台管理前端模板。 (配套 Java 后端 [youlai-boot](https://gitee.com/youlaiorg/youlai-boot) 和 Node 后端 [youlai-nest](https://gitee.com/youlaiorg/youlai-nest))。
|
||||||
|
|
||||||
|
|
||||||
## 项目特色
|
## 项目特色
|
||||||
|
|
||||||
- **简洁易用**:基于 [vue-element-admin](https://gitee.com/panjiachen/vue-element-admin) 升级的 Vue3 版本,无过渡封装 ,易上手。
|
- **简洁易用**:基于 [vue-element-admin](https://gitee.com/panjiachen/vue-element-admin) 升级的 Vue3 版本,无过渡封装 ,易上手。
|
||||||
|
- **数据交互**: 支持 `Mock` 数据和[线上接口文档](https://www.apifox.cn/apidoc/shared-195e783f-4d85-4235-a038-eec696de4ea5),并提供配套的 [Java](https://gitee.com/youlaiorg/youlai-boot) 和 [Node](https://gitee.com/youlaiorg/youlai-nest) 后端源码。
|
||||||
|
|
||||||
- **数据交互**:同时支持本地 `Mock` 和线上接口,配套 [Java 后端源码](https://gitee.com/youlaiorg/youlai-boot)和[在线接口文档](https://www.apifox.cn/apidoc/shared-195e783f-4d85-4235-a038-eec696de4ea5)。
|
- **系统功能:** 提供用户管理、角色管理、菜单管理、部门管理、字典管理等功能模块。
|
||||||
|
- **权限管理:** 支持动态路由、按钮权限、角色权限和数据权限等多种权限管理方式。
|
||||||
- **权限管理**:用户、角色、菜单、字典、部门等完善的权限系统功能。
|
|
||||||
|
|
||||||
- **基础设施**:动态路由、按钮权限、国际化、代码规范、Git 提交规范、常用组件封装。
|
|
||||||
|
|
||||||
|
- **基础设施:** 提供国际化、多布局、暗黑模式、全屏、水印、接口文档和代码生成器等功能。
|
||||||
- **持续更新**:项目持续开源更新,实时更新工具和依赖。
|
- **持续更新**:项目持续开源更新,实时更新工具和依赖。
|
||||||
|
|
||||||
|
|
||||||
|
## 项目截图
|
||||||
|
|
||||||
## 项目预览
|

|
||||||
|
|
||||||

|

|
||||||
|
|
||||||

|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## 项目源码
|
## 项目源码
|
||||||
|
|
||||||
@@ -100,29 +95,34 @@ pnpm run dev
|
|||||||
|
|
||||||
## 项目部署
|
## 项目部署
|
||||||
|
|
||||||
|
执行 `pnpm run build` 命令后,项目将被打包并生成 `dist` 目录。接下来,将 `dist` 目录下的文件上传到服务器 `/usr/share/nginx/html` 目录下,并配置 Nginx 进行反向代理。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 项目打包
|
|
||||||
pnpm run build
|
pnpm run build
|
||||||
|
```
|
||||||
|
|
||||||
# 上传文件至远程服务器
|
以下是 Nginx 的配置示例:
|
||||||
将本地打包生成的 dist 目录下的所有文件拷贝至服务器的 /usr/share/nginx/html 目录。
|
|
||||||
|
|
||||||
# nginx.cofig 配置
|
```nginx
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name localhost;
|
server_name localhost;
|
||||||
location / {
|
|
||||||
root /usr/share/nginx/html;
|
location / {
|
||||||
index index.html index.htm;
|
root /usr/share/nginx/html;
|
||||||
}
|
index index.html index.htm;
|
||||||
# 反向代理配置
|
}
|
||||||
location /prod-api/ {
|
|
||||||
# api.youlai.tech 替换后端API地址,注意保留后面的斜杠 /
|
# 反向代理配置
|
||||||
proxy_pass http://api.youlai.tech/;
|
location /prod-api/ {
|
||||||
}
|
# 请将 api.youlai.tech 替换为您的后端 API 地址,并注意保留后面的斜杠 /
|
||||||
|
proxy_pass http://api.youlai.tech/;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
更多详细信息,请参考这篇文章:[Nginx 安装和配置](https://blog.csdn.net/u013737132/article/details/145667694)。
|
||||||
|
|
||||||
## 本地Mock
|
## 本地Mock
|
||||||
|
|
||||||
项目同时支持在线和本地 Mock 接口,默认使用线上接口,如需替换为 Mock 接口,修改文件 `.env.development` 的 `VITE_MOCK_DEV_SERVER` 为 `true` **即可**。
|
项目同时支持在线和本地 Mock 接口,默认使用线上接口,如需替换为 Mock 接口,修改文件 `.env.development` 的 `VITE_MOCK_DEV_SERVER` 为 `true` **即可**。
|
||||||
@@ -166,8 +166,8 @@ server {
|
|||||||
|
|
||||||
- [基于 Vue3 + Vite + TypeScript + Element-Plus 从0到1搭建后台管理系统](https://blog.csdn.net/u013737132/article/details/130191394)
|
- [基于 Vue3 + Vite + TypeScript + Element-Plus 从0到1搭建后台管理系统](https://blog.csdn.net/u013737132/article/details/130191394)
|
||||||
|
|
||||||
- [ESLint+Prettier+Stylelint+EditorConfig 约束和统一前端代码规范](https://blog.csdn.net/u013737132/article/details/130190788)
|
- [ESLint+Prettier+Stylelint+EditorConfig 约束和统一前端代码规范](https://youlai.blog.csdn.net/article/details/145608723)
|
||||||
- [Husky + Lint-staged + Commitlint + Commitizen + cz-git 配置 Git 提交规范](https://blog.csdn.net/u013737132/article/details/130191363)
|
- [Husky + Lint-staged + Commitlint + Commitizen + cz-git 配置 Git 提交规范](https://youlai.blog.csdn.net/article/details/145615236)
|
||||||
|
|
||||||
|
|
||||||
## 提交规范
|
## 提交规范
|
||||||
@@ -190,15 +190,13 @@ Thanks to all the contributors!
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
## 交流群🚀
|
## 加群交流
|
||||||
|
|
||||||
> **关注「有来技术」公众号,获取交流群二维码。**
|
> **关注「有来技术」公众号,点击菜单“交流群”获取加群二维码。**
|
||||||
>
|
>
|
||||||
> 如果交流群的二维码过期,请加微信(haoxianrui)并备注「前端」、「后端」或「全栈」以获取最新二维码。
|
> 如果二维码过期,请加微信(haoxianrui)备注「前端」、「后端」或「全栈」拉你进群。
|
||||||
>
|
>
|
||||||
> 为确保交流群质量,防止营销广告人群混入,我们采取了此措施。望各位理解!
|
> 交流群仅限技术交流,为过滤广告营销暂设此门槛,感谢理解与配合
|
||||||
|
|
||||||
| 公众号 | 交流群 |
|

|
||||||
|:----:|:----:|
|
|
||||||
|  |  |
|
|
||||||
|
|
||||||
|
|||||||
@@ -74,9 +74,7 @@ module.exports = {
|
|||||||
breaklineNumber: 100,
|
breaklineNumber: 100,
|
||||||
breaklineChar: "|",
|
breaklineChar: "|",
|
||||||
skipQuestions: [],
|
skipQuestions: [],
|
||||||
issuePrefixes: [
|
issuePrefixes: [{ value: "closed", name: "closed: ISSUES has been processed" }],
|
||||||
{ value: "closed", name: "closed: ISSUES has been processed" },
|
|
||||||
],
|
|
||||||
customIssuePrefixAlign: "top",
|
customIssuePrefixAlign: "top",
|
||||||
emptyIssuePrefixAlias: "skip",
|
emptyIssuePrefixAlias: "skip",
|
||||||
customIssuePrefixAlias: "custom",
|
customIssuePrefixAlias: "custom",
|
||||||
|
|||||||
118
eslint.config.js
118
eslint.config.js
@@ -1,115 +1,99 @@
|
|||||||
|
// https://eslint.nodejs.cn/docs/latest/use/configure/configuration-files
|
||||||
|
|
||||||
import globals from "globals";
|
import globals from "globals";
|
||||||
import js from "@eslint/js";
|
import pluginJs from "@eslint/js"; // JavaScript 规则
|
||||||
|
import pluginVue from "eslint-plugin-vue"; // Vue 规则
|
||||||
|
import pluginTypeScript from "@typescript-eslint/eslint-plugin"; // TypeScript 规则
|
||||||
|
|
||||||
// ESLint 核心插件
|
import parserVue from "vue-eslint-parser"; // Vue 解析器
|
||||||
import pluginVue from "eslint-plugin-vue";
|
import parserTypeScript from "@typescript-eslint/parser"; // TypeScript 解析器
|
||||||
import pluginTypeScript from "@typescript-eslint/eslint-plugin";
|
|
||||||
|
|
||||||
// Prettier 插件及配置
|
import configPrettier from "eslint-config-prettier"; // 禁用与 Prettier 冲突的规则
|
||||||
import configPrettier from "eslint-config-prettier";
|
import pluginPrettier from "eslint-plugin-prettier"; // 运行 Prettier 规则
|
||||||
import pluginPrettier from "eslint-plugin-prettier";
|
|
||||||
|
|
||||||
// 解析器
|
// 解析自动导入配置
|
||||||
import * as parserVue from "vue-eslint-parser";
|
import fs from "fs";
|
||||||
import * as parserTypeScript from "@typescript-eslint/parser";
|
const autoImportConfig = JSON.parse(fs.readFileSync(".eslintrc-auto-import.json", "utf-8"));
|
||||||
|
|
||||||
// 定义 ESLint 配置
|
/** @type {import('eslint').Linter.Config[]} */
|
||||||
export default [
|
export default [
|
||||||
// 通用 JavaScript 配置
|
// 指定检查文件和忽略文件
|
||||||
|
{
|
||||||
|
files: ["**/*.{js,mjs,cjs,ts,vue}"],
|
||||||
|
ignores: ["**/*.d.ts"],
|
||||||
|
},
|
||||||
|
// 全局配置
|
||||||
{
|
{
|
||||||
...js.configs.recommended,
|
|
||||||
ignores: ["**/.*", "dist/*", "*.d.ts", "public/*", "src/assets/**"],
|
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: {
|
globals: {
|
||||||
...globals.browser, // 浏览器变量 (window, document 等)
|
...globals.browser,
|
||||||
...globals.node, // Node.js 变量 (process, require 等)
|
...globals.node,
|
||||||
|
...autoImportConfig.globals,
|
||||||
|
...{
|
||||||
|
PageQuery: "readonly",
|
||||||
|
PageResult: "readonly",
|
||||||
|
OptionType: "readonly",
|
||||||
|
ResponseData: "readonly",
|
||||||
|
ExcelResult: "readonly",
|
||||||
|
TagView: "readonly",
|
||||||
|
AppSettings: "readonly",
|
||||||
|
__APP_INFO__: "readonly",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: { prettier: pluginPrettier },
|
||||||
prettier: pluginPrettier,
|
|
||||||
},
|
|
||||||
rules: {
|
rules: {
|
||||||
...configPrettier.rules,
|
...configPrettier.rules, // 关闭与 Prettier 冲突的规则
|
||||||
...pluginPrettier.configs.recommended.rules,
|
...pluginPrettier.configs.recommended.rules, // 启用 Prettier 规则
|
||||||
"no-debug": "off", // 禁止 debugger
|
"prettier/prettier": "error", // 强制 Prettier 格式化
|
||||||
"prettier/prettier": [
|
"no-unused-vars": [
|
||||||
"error",
|
"error",
|
||||||
{
|
{
|
||||||
endOfLine: "auto", // 自动识别换行符
|
argsIgnorePattern: "^_", // 忽略参数名以 _ 开头的参数未使用警告
|
||||||
|
varsIgnorePattern: "^[A-Z0-9_]+$", // 忽略变量名为大写字母、数字或下划线组合的未使用警告(枚举定义未使用场景)
|
||||||
|
ignoreRestSiblings: true, // 忽略解构赋值中同级未使用变量的警告
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// JavaScript 配置
|
||||||
|
pluginJs.configs.recommended,
|
||||||
|
|
||||||
// TypeScript 配置
|
// TypeScript 配置
|
||||||
{
|
{
|
||||||
files: ["**/*.?([cm])ts"],
|
files: ["**/*.ts"],
|
||||||
|
ignores: ["**/*.d.ts"], // 排除d.ts文件
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parser: parserTypeScript,
|
parser: parserTypeScript,
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
sourceType: "module",
|
sourceType: "module",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: { "@typescript-eslint": pluginTypeScript },
|
||||||
"@typescript-eslint": pluginTypeScript,
|
|
||||||
},
|
|
||||||
rules: {
|
rules: {
|
||||||
...pluginTypeScript.configs.strict.rules,
|
...pluginTypeScript.configs.strict.rules, // TypeScript 严格规则
|
||||||
"@typescript-eslint/no-explicit-any": "off", // 允许使用 any
|
"@typescript-eslint/no-explicit-any": "off", // 允许使用 any
|
||||||
"@typescript-eslint/no-empty-function": "off", // 允许空函数
|
"@typescript-eslint/no-empty-function": "off", // 允许空函数
|
||||||
"@typescript-eslint/no-empty-object-type": "off", // 允许空对象类型
|
"@typescript-eslint/no-empty-object-type": "off", // 允许空对象类型
|
||||||
"@typescript-eslint/consistent-type-imports": [
|
|
||||||
"error",
|
|
||||||
{ disallowTypeAnnotations: false, fixStyle: "inline-type-imports" },
|
|
||||||
], // 统一类型导入风格
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// TypeScript 声明文件的特殊配置
|
// Vue 配置
|
||||||
{
|
|
||||||
files: ["**/*.d.ts"],
|
|
||||||
rules: {
|
|
||||||
"eslint-comments/no-unlimited-disable": "off",
|
|
||||||
"unused-imports/no-unused-vars": "off",
|
|
||||||
"@typescript-eslint/ban-ts-comment": "off", // 允许使用 @ts-nocheck 注释
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// JavaScript (commonjs) 配置
|
|
||||||
{
|
|
||||||
files: ["**/*.?([cm])js"],
|
|
||||||
rules: {
|
|
||||||
"@typescript-eslint/no-var-requires": "off", // 允许 require
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Vue 文件配置
|
|
||||||
{
|
{
|
||||||
files: ["**/*.vue"],
|
files: ["**/*.vue"],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parser: parserVue,
|
parser: parserVue,
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
parser: "@typescript-eslint/parser",
|
parser: parserTypeScript,
|
||||||
sourceType: "module",
|
sourceType: "module",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: { vue: pluginVue, "@typescript-eslint": pluginTypeScript },
|
||||||
vue: pluginVue,
|
|
||||||
},
|
|
||||||
processor: pluginVue.processors[".vue"],
|
processor: pluginVue.processors[".vue"],
|
||||||
rules: {
|
rules: {
|
||||||
...pluginVue.configs["vue3-recommended"].rules,
|
...pluginVue.configs["vue3-recommended"].rules, // Vue 3 推荐规则
|
||||||
"vue/no-v-html": "off", // 允许 v-html
|
"vue/no-v-html": "off", // 允许 v-html
|
||||||
"vue/require-default-prop": "off", // 允许没有默认值的 prop
|
"vue/multi-word-component-names": "off", // 允许单个单词组件名
|
||||||
"vue/multi-word-component-names": "off", // 关闭组件名称多词要求
|
|
||||||
"vue/html-self-closing": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
html: { void: "always", normal: "always", component: "always" },
|
|
||||||
svg: "always",
|
|
||||||
math: "always",
|
|
||||||
},
|
|
||||||
], // 自闭合标签
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
81
package.json
81
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "vue3-element-admin",
|
"name": "vue3-element-admin",
|
||||||
"version": "2.20.4",
|
"version": "2.23.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -9,9 +9,9 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"build-only": "vite build",
|
"build-only": "vite build",
|
||||||
"type-check": "vue-tsc --noEmit",
|
"type-check": "vue-tsc --noEmit",
|
||||||
"lint:eslint": "eslint --fix ./src",
|
"lint:eslint": "eslint --cache \"src/**/*.{vue,ts}\" --fix",
|
||||||
"lint:prettier": "prettier --write \"**/*.{js,cjs,ts,json,tsx,css,less,scss,vue,html,md}\"",
|
"lint:prettier": "prettier --write \"**/*.{js,cjs,ts,json,css,scss,vue,html,md}\"",
|
||||||
"lint:stylelint": "stylelint \"**/*.{css,scss,vue}\" --fix",
|
"lint:stylelint": "stylelint --cache \"**/*.{css,scss,vue}\" --fix",
|
||||||
"lint:lint-staged": "lint-staged",
|
"lint:lint-staged": "lint-staged",
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
@@ -46,70 +46,73 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.1",
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
"@stomp/stompjs": "^7.0.0",
|
"@stomp/stompjs": "^7.0.0",
|
||||||
"@vueuse/core": "^10.11.1",
|
"@vueuse/core": "^12.6.1",
|
||||||
"@wangeditor/editor": "^5.1.23",
|
"@wangeditor-next/editor": "^5.6.31",
|
||||||
"@wangeditor/editor-for-vue": "5.1.10",
|
"@wangeditor-next/editor-for-vue": "^5.1.14",
|
||||||
|
"animate.css": "^4.1.1",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"codemirror": "^5.65.18",
|
"codemirror": "^5.65.18",
|
||||||
"codemirror-editor-vue3": "^2.8.0",
|
"codemirror-editor-vue3": "^2.8.0",
|
||||||
|
"default-passive-events": "^2.0.0",
|
||||||
"echarts": "^5.6.0",
|
"echarts": "^5.6.0",
|
||||||
"element-plus": "^2.9.3",
|
"element-plus": "^2.9.4",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"nprogress": "^0.2.0",
|
"nprogress": "^0.2.0",
|
||||||
"path-browserify": "^1.0.1",
|
"path-browserify": "^1.0.1",
|
||||||
"path-to-regexp": "^6.3.0",
|
"path-to-regexp": "^8.2.0",
|
||||||
"pinia": "^2.3.0",
|
"pinia": "^3.0.1",
|
||||||
"qs": "^6.14.0",
|
"qs": "^6.14.0",
|
||||||
"sortablejs": "^1.15.6",
|
"sortablejs": "^1.15.6",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-i18n": "9.9.1",
|
"vue-i18n": "^11.1.1",
|
||||||
"vue-router": "^4.5.0"
|
"vue-router": "^4.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^19.6.1",
|
"@commitlint/cli": "^19.7.1",
|
||||||
"@commitlint/config-conventional": "^19.6.0",
|
"@commitlint/config-conventional": "^19.7.1",
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.20.0",
|
||||||
|
"@iconify/utils": "^2.3.0",
|
||||||
"@types/codemirror": "^5.60.15",
|
"@types/codemirror": "^5.60.15",
|
||||||
"@types/lodash": "^4.17.14",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.13.4",
|
||||||
"@types/nprogress": "^0.2.3",
|
"@types/nprogress": "^0.2.3",
|
||||||
"@types/path-browserify": "^1.0.3",
|
"@types/path-browserify": "^1.0.3",
|
||||||
"@types/qs": "^6.9.18",
|
"@types/qs": "^6.9.18",
|
||||||
"@types/sortablejs": "^1.15.8",
|
"@types/sortablejs": "^1.15.8",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.20.0",
|
"@typescript-eslint/eslint-plugin": "^8.24.0",
|
||||||
"@typescript-eslint/parser": "^8.20.0",
|
"@typescript-eslint/parser": "^8.24.0",
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"commitizen": "^4.3.1",
|
"commitizen": "^4.3.1",
|
||||||
"cz-git": "1.9.4",
|
"cz-git": "^1.11.0",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.20.1",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
"eslint-plugin-prettier": "^5.2.3",
|
"eslint-plugin-prettier": "^5.2.3",
|
||||||
"eslint-plugin-vue": "^9.32.0",
|
"eslint-plugin-vue": "^9.32.0",
|
||||||
"globals": "^15.14.0",
|
"globals": "^15.15.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^15.4.1",
|
"lint-staged": "^15.4.3",
|
||||||
"postcss": "^8.5.1",
|
"postcss": "^8.5.2",
|
||||||
"postcss-html": "^1.8.0",
|
"postcss-html": "^1.8.0",
|
||||||
"postcss-scss": "^4.0.9",
|
"postcss-scss": "^4.0.9",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.5.1",
|
||||||
"sass": "^1.83.4",
|
"sass": "^1.85.0",
|
||||||
"stylelint": "^16.13.2",
|
"stylelint": "^16.14.1",
|
||||||
"stylelint-config-html": "^1.1.0",
|
"stylelint-config-html": "^1.1.0",
|
||||||
"stylelint-config-recess-order": "^5.1.1",
|
"stylelint-config-recess-order": "^6.0.0",
|
||||||
|
"stylelint-config-recommended": "^15.0.0",
|
||||||
"stylelint-config-recommended-scss": "^14.1.0",
|
"stylelint-config-recommended-scss": "^14.1.0",
|
||||||
"stylelint-config-recommended-vue": "^1.5.0",
|
"stylelint-config-recommended-vue": "^1.6.0",
|
||||||
"stylelint-config-standard": "^36.0.1",
|
"stylelint-prettier": "^5.0.3",
|
||||||
"terser": "^5.37.0",
|
"terser": "^5.39.0",
|
||||||
"typescript": "5.5.4",
|
"typescript": "^5.7.3",
|
||||||
"typescript-eslint": "^8.20.0",
|
"typescript-eslint": "^8.24.0",
|
||||||
"unocss": "0.65.3",
|
"unocss": "65.4.3",
|
||||||
"unplugin-auto-import": "^0.18.6",
|
"unplugin-auto-import": "^19.0.0",
|
||||||
"unplugin-vue-components": "^0.27.5",
|
"unplugin-vue-components": "^28.0.0",
|
||||||
"vite": "^6.0.7",
|
"vite": "^6.1.0",
|
||||||
"vite-plugin-mock-dev-server": "^1.8.3",
|
"vite-plugin-mock-dev-server": "^1.8.4",
|
||||||
"vite-plugin-svg-icons": "^2.0.1",
|
|
||||||
"vue-eslint-parser": "^9.4.3",
|
"vue-eslint-parser": "^9.4.3",
|
||||||
"vue-tsc": "^2.2.0"
|
"vue-tsc": "^2.2.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,19 +22,17 @@ const AuthAPI = {
|
|||||||
|
|
||||||
/** 刷新 token 接口*/
|
/** 刷新 token 接口*/
|
||||||
refreshToken(refreshToken: string) {
|
refreshToken(refreshToken: string) {
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("refreshToken", refreshToken);
|
|
||||||
return request<any, LoginResult>({
|
return request<any, LoginResult>({
|
||||||
url: `${AUTH_BASE_URL}/refresh-token`,
|
url: `${AUTH_BASE_URL}/refresh-token`,
|
||||||
method: "post",
|
method: "post",
|
||||||
data: formData,
|
params: { refreshToken: refreshToken },
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: "no-auth",
|
Authorization: "no-auth",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 注销接口 */
|
/** 注销登录接口 */
|
||||||
logout() {
|
logout() {
|
||||||
return request({
|
return request({
|
||||||
url: `${AUTH_BASE_URL}/logout`,
|
url: `${AUTH_BASE_URL}/logout`,
|
||||||
|
|||||||
@@ -2,16 +2,25 @@ import request from "@/utils/request";
|
|||||||
|
|
||||||
const FileAPI = {
|
const FileAPI = {
|
||||||
/**
|
/**
|
||||||
* 文件上传地址
|
* 上传文件
|
||||||
|
*
|
||||||
|
* @param formData
|
||||||
*/
|
*/
|
||||||
uploadUrl: import.meta.env.VITE_APP_BASE_API + "/api/v1/files",
|
upload(formData: FormData) {
|
||||||
|
return request<any, FileInfo>({
|
||||||
|
url: "/api/v1/files",
|
||||||
|
method: "post",
|
||||||
|
data: formData,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "multipart/form-data",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 上传文件
|
* 上传文件
|
||||||
*
|
|
||||||
* @param file
|
|
||||||
*/
|
*/
|
||||||
upload(file: File) {
|
uploadFile(file: File) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
return request<any, FileInfo>({
|
return request<any, FileInfo>({
|
||||||
@@ -29,7 +38,7 @@ const FileAPI = {
|
|||||||
*
|
*
|
||||||
* @param filePath 文件完整路径
|
* @param filePath 文件完整路径
|
||||||
*/
|
*/
|
||||||
deleteByPath(filePath?: string) {
|
delete(filePath?: string) {
|
||||||
return request({
|
return request({
|
||||||
url: "/api/v1/files",
|
url: "/api/v1/files",
|
||||||
method: "delete",
|
method: "delete",
|
||||||
@@ -42,7 +51,7 @@ const FileAPI = {
|
|||||||
* @param url
|
* @param url
|
||||||
* @param fileName
|
* @param fileName
|
||||||
*/
|
*/
|
||||||
downloadFile(url: string, fileName?: string) {
|
download(url: string, fileName?: string) {
|
||||||
return request({
|
return request({
|
||||||
url: url,
|
url: url,
|
||||||
method: "get",
|
method: "get",
|
||||||
|
|||||||
@@ -240,7 +240,7 @@
|
|||||||
</el-icon>
|
</el-icon>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<svg-icon :icon-class="scope.row[col.prop]" />
|
<div class="i-svg:{{ scope.row[col.prop] }}" />
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
@@ -453,7 +453,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import SvgIcon from "@/components/SvgIcon/index.vue";
|
|
||||||
import { hasAuth } from "@/plugins/permission";
|
import { hasAuth } from "@/plugins/permission";
|
||||||
import { useDateFormat, useThrottleFn } from "@vueuse/core";
|
import { useDateFormat, useThrottleFn } from "@vueuse/core";
|
||||||
import {
|
import {
|
||||||
@@ -951,7 +950,7 @@ function exportPageData(formData: IObject = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 浏览器保存文件
|
// 浏览器保存文件
|
||||||
function saveXlsx(fileData: BlobPart, fileName: string) {
|
function saveXlsx(fileData: any, fileName: string) {
|
||||||
const fileType =
|
const fileType =
|
||||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8";
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8";
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
<slot
|
<slot
|
||||||
:name="item.slotName ?? item.prop"
|
:name="item.slotName ?? item.prop"
|
||||||
:prop="item.prop"
|
:prop="item.prop"
|
||||||
:formData="formData"
|
:form-data="formData"
|
||||||
:attrs="item.attrs"
|
:attrs="item.attrs"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -137,11 +137,11 @@ function getFormData(key?: string) {
|
|||||||
// 设置表单值
|
// 设置表单值
|
||||||
function setFormData(data: IObject) {
|
function setFormData(data: IObject) {
|
||||||
for (const key in formData) {
|
for (const key in formData) {
|
||||||
if (formData.hasOwnProperty(key) && key in data) {
|
if (Object.prototype.hasOwnProperty.call(formData, key) && key in data) {
|
||||||
formData[key] = data[key];
|
formData[key] = data[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (data?.hasOwnProperty(props.pk)) {
|
if (Object.prototype.hasOwnProperty.call(data, props.pk)) {
|
||||||
formData[props.pk] = data[props.pk];
|
formData[props.pk] = data[props.pk];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,7 +90,7 @@
|
|||||||
<slot
|
<slot
|
||||||
:name="item.slotName ?? item.prop"
|
:name="item.slotName ?? item.prop"
|
||||||
:prop="item.prop"
|
:prop="item.prop"
|
||||||
:formData="formData"
|
:form-data="formData"
|
||||||
:attrs="item.attrs"
|
:attrs="item.attrs"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -208,7 +208,7 @@
|
|||||||
<slot
|
<slot
|
||||||
:name="item.slotName ?? item.prop"
|
:name="item.slotName ?? item.prop"
|
||||||
:prop="item.prop"
|
:prop="item.prop"
|
||||||
:formData="formData"
|
:form-data="formData"
|
||||||
:attrs="item.attrs"
|
:attrs="item.attrs"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -294,11 +294,11 @@ function getFormData(key?: string) {
|
|||||||
// 设置表单值
|
// 设置表单值
|
||||||
function setFormData(data: IObject) {
|
function setFormData(data: IObject) {
|
||||||
for (const key in formData) {
|
for (const key in formData) {
|
||||||
if (formData.hasOwnProperty(key) && key in data) {
|
if (Object.prototype.hasOwnProperty.call(formData, key) && key in data) {
|
||||||
formData[key] = data[key];
|
formData[key] = data[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (data?.hasOwnProperty(pk)) {
|
if (Object.prototype.hasOwnProperty.call(data, pk)) {
|
||||||
formData[pk] = data[pk];
|
formData[pk] = data[pk];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
81
src/components/ECharts/index.vue
Normal file
81
src/components/ECharts/index.vue
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<!--
|
||||||
|
* 基于 ECharts 的 Vue3 图表组件
|
||||||
|
* 版权所有 © 2021-present 有来开源组织
|
||||||
|
*
|
||||||
|
* 开源协议:https://opensource.org/licenses/MIT
|
||||||
|
* 项目地址:https://gitee.com/youlaiorg/vue3-element-admin
|
||||||
|
* 参考:https://echarts.apache.org/handbook/zh/basics/import/#%E6%8C%89%E9%9C%80%E5%BC%95%E5%85%A5-echarts-%E5%9B%BE%E8%A1%A8%E5%92%8C%E7%BB%84%E4%BB%B6
|
||||||
|
*
|
||||||
|
* 在使用时,请保留此注释,感谢您对开源的支持!
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="chartRef" :style="{ width, height }"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// 引入 echarts 核心模块,核心模块提供了 echarts 使用必须要的接口。
|
||||||
|
import * as echarts from "echarts/core";
|
||||||
|
// 引入柱状、折线和饼图常用图表
|
||||||
|
import { BarChart, LineChart, PieChart } from "echarts/charts";
|
||||||
|
// 引入标题,提示框,直角坐标系,数据集,内置数据转换器组件,
|
||||||
|
import { GridComponent, TooltipComponent, LegendComponent } from "echarts/components";
|
||||||
|
// 引入 Canvas 渲染器,注意引入 CanvasRenderer 或者 SVGRenderer 是必须的一步
|
||||||
|
import { CanvasRenderer } from "echarts/renderers";
|
||||||
|
|
||||||
|
import { useResizeObserver } from "@vueuse/core";
|
||||||
|
|
||||||
|
// 按需注册组件
|
||||||
|
echarts.use([
|
||||||
|
CanvasRenderer,
|
||||||
|
BarChart,
|
||||||
|
LineChart,
|
||||||
|
PieChart,
|
||||||
|
GridComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
LegendComponent,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
options: echarts.EChartsCoreOption;
|
||||||
|
width?: string;
|
||||||
|
height?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const chartRef = ref<HTMLDivElement | null>(null);
|
||||||
|
let chartInstance: echarts.ECharts | null = null;
|
||||||
|
|
||||||
|
// 初始化图表
|
||||||
|
const initChart = () => {
|
||||||
|
if (chartRef.value) {
|
||||||
|
chartInstance = echarts.init(chartRef.value);
|
||||||
|
if (props.options) {
|
||||||
|
chartInstance.setOption(props.options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听尺寸变化,自动调整
|
||||||
|
useResizeObserver(chartRef, () => {
|
||||||
|
chartInstance?.resize();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听 options 变化,更新图表
|
||||||
|
watch(
|
||||||
|
() => props.options,
|
||||||
|
(newOptions) => {
|
||||||
|
if (chartInstance && newOptions) {
|
||||||
|
chartInstance.setOption(newOptions);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
nextTick(() => initChart());
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
chartInstance?.dispose();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div @click="toggle">
|
<div @click="toggle">
|
||||||
<svg-icon :icon-class="isFullscreen ? 'fullscreen-exit' : 'fullscreen'" />
|
<div :class="`i-svg:` + (isFullscreen ? 'fullscreen-exit' : 'fullscreen')" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
<!-- 汉堡按钮组件:展开/收缩菜单 -->
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div class="hamburger-wrapper" @click="toggleClick">
|
||||||
class="px-[15px] flex items-center justify-center color-[var(--el-text-color-regular)]"
|
<div :class="['i-svg:collapse', { hamburger: true, 'is-active': isActive }]" />
|
||||||
@click="toggleClick"
|
|
||||||
>
|
|
||||||
<svg-icon icon-class="collapse" :class="{ hamburger: true, 'is-active': isActive }" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -25,13 +21,25 @@ function toggleClick() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.hamburger {
|
.hamburger-wrapper {
|
||||||
vertical-align: middle;
|
display: flex;
|
||||||
cursor: pointer;
|
align-items: center;
|
||||||
transform: scaleX(-1);
|
justify-content: center;
|
||||||
}
|
padding: 0 15px;
|
||||||
|
|
||||||
.hamburger.is-active {
|
.hamburger {
|
||||||
transform: scaleX(1);
|
vertical-align: middle;
|
||||||
|
cursor: pointer;
|
||||||
|
transform: scaleX(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger.is-active {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.layout-mix {
|
||||||
|
.hamburger-wrapper {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<component :is="selectedIcon.replace('el-icon-', '')" />
|
<component :is="selectedIcon.replace('el-icon-', '')" />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<svg-icon :icon-class="selectedIcon" />
|
<div :class="`i-svg:${selectedIcon}`" />
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
@click="selectIcon(icon)"
|
@click="selectIcon(icon)"
|
||||||
>
|
>
|
||||||
<el-tooltip :content="icon" placement="bottom" effect="light">
|
<el-tooltip :content="icon" placement="bottom" effect="light">
|
||||||
<svg-icon :icon-class="icon" />
|
<div :class="`i-svg:${icon}`" />
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-dropdown trigger="click" @command="handleLanguageChange">
|
<el-dropdown trigger="click" @command="handleLanguageChange">
|
||||||
<div>
|
<div class="i-svg:language" />
|
||||||
<svg-icon icon-class="language" :size="size" />
|
|
||||||
</div>
|
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
<el-dropdown-item
|
<el-dropdown-item
|
||||||
@@ -19,7 +17,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from "vue-i18n";
|
|
||||||
import { useAppStore } from "@/store/modules/app";
|
import { useAppStore } from "@/store/modules/app";
|
||||||
import { LanguageEnum } from "@/enums/LanguageEnum";
|
import { LanguageEnum } from "@/enums/LanguageEnum";
|
||||||
|
|
||||||
@@ -38,6 +35,11 @@ const langOptions = [
|
|||||||
const appStore = useAppStore();
|
const appStore = useAppStore();
|
||||||
const { locale, t } = useI18n();
|
const { locale, t } = useI18n();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理语言切换
|
||||||
|
*
|
||||||
|
* @param lang 语言(zh-cn、en)
|
||||||
|
*/
|
||||||
function handleLanguageChange(lang: string) {
|
function handleLanguageChange(lang: string) {
|
||||||
locale.value = lang;
|
locale.value = lang;
|
||||||
appStore.changeLanguage(lang);
|
appStore.changeLanguage(lang);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div @click="openSearchModal">
|
<div @click="openSearchModal">
|
||||||
<svg-icon icon-class="search" />
|
<div class="i-svg:search" />
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="isModalVisible"
|
v-model="isModalVisible"
|
||||||
width="30%"
|
width="30%"
|
||||||
@@ -38,8 +38,8 @@
|
|||||||
<el-icon v-if="item.icon && item.icon.startsWith('el-icon')">
|
<el-icon v-if="item.icon && item.icon.startsWith('el-icon')">
|
||||||
<component :is="item.icon.replace('el-icon-', '')" />
|
<component :is="item.icon.replace('el-icon-', '')" />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
<svg-icon v-else-if="item.icon" :icon-class="item.icon" />
|
<div v-else-if="item.icon" :class="`i-svg:${item.icon}`" />
|
||||||
<svg-icon v-else icon-class="menu" />
|
<div v-else class="i-svg:menu" />
|
||||||
{{ item.title }}
|
{{ item.title }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -48,14 +48,15 @@
|
|||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="dialog-footer">
|
<div class="dialog-footer">
|
||||||
<svg-icon icon-class="enter" size="20px" />
|
<div class="i-svg:enter w-5 h-5" />
|
||||||
<span>选择</span>
|
<span>选择</span>
|
||||||
|
|
||||||
<svg-icon icon-class="down" size="20px" class="ml-5" />
|
<div class="i-svg:down w-5 h-5 ml-5" />
|
||||||
<svg-icon icon-class="up" size="20px" class="ml-1" />
|
<div class="i-svg:up w-5 h-5 ml-5" />
|
||||||
<span>切换</span>
|
<span>切换</span>
|
||||||
|
|
||||||
<svg-icon icon-class="esc" size="20px" class="ml-5" />
|
<div class="i-svg:esc w-5 h-5ml-5" />
|
||||||
|
|
||||||
<span>退出</span>
|
<span>退出</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps({
|
const props = defineProps({
|
||||||
total: {
|
total: {
|
||||||
required: true,
|
required: true,
|
||||||
type: Number as PropType<number>,
|
type: Number as PropType<number>,
|
||||||
@@ -53,13 +53,26 @@ const currentPage = defineModel("page", {
|
|||||||
required: true,
|
required: true,
|
||||||
default: 1,
|
default: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
const pageSize = defineModel("limit", {
|
const pageSize = defineModel("limit", {
|
||||||
type: Number,
|
type: Number,
|
||||||
required: true,
|
required: true,
|
||||||
default: 10,
|
default: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.total,
|
||||||
|
(newVal: number) => {
|
||||||
|
const lastPage = Math.ceil(newVal / pageSize.value);
|
||||||
|
if (newVal > 0 && currentPage.value > lastPage) {
|
||||||
|
currentPage.value = lastPage;
|
||||||
|
emit("pagination", { page: currentPage.value, limit: pageSize.value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
function handleSizeChange(val: number) {
|
function handleSizeChange(val: number) {
|
||||||
|
currentPage.value = 1;
|
||||||
emit("pagination", { page: currentPage.value, limit: val });
|
emit("pagination", { page: currentPage.value, limit: val });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<el-tooltip :content="$t('sizeSelect.tooltip')" effect="dark" placement="bottom">
|
<el-tooltip :content="$t('sizeSelect.tooltip')" effect="dark" placement="bottom">
|
||||||
<el-dropdown trigger="click" @command="handleSizeChange">
|
<el-dropdown trigger="click" @command="handleSizeChange">
|
||||||
<div>
|
<div>
|
||||||
<svg-icon icon-class="size" />
|
<div class="i-svg:size" />
|
||||||
</div>
|
</div>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg aria-hidden="true" class="svg-icon" :style="'width:' + size + ';height:' + size">
|
|
||||||
<use :xlink:href="symbolId" :fill="color" />
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const props = defineProps({
|
|
||||||
prefix: {
|
|
||||||
type: String,
|
|
||||||
default: "icon",
|
|
||||||
},
|
|
||||||
iconClass: {
|
|
||||||
type: String,
|
|
||||||
required: false,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
color: {
|
|
||||||
type: String,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
type: String,
|
|
||||||
default: "1em",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const symbolId = computed(() => `#${props.prefix}-${props.iconClass}`);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.svg-icon {
|
|
||||||
display: inline-block;
|
|
||||||
width: 1em;
|
|
||||||
height: 1em;
|
|
||||||
overflow: hidden;
|
|
||||||
vertical-align: -0.15em; /* 因icon大小被设置为和字体大小一致,而span等标签的下边缘会和字体的基线对齐,故需设置一个往下的偏移比例,来纠正视觉上的未对齐效果 */
|
|
||||||
outline: none;
|
|
||||||
fill: currentcolor; /* 定义元素的颜色,currentColor是一个变量,这个变量的值就表示当前元素的color值,如果当前元素未设置color值,则从父元素继承 */
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -143,7 +143,7 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, reactive, computed } from "vue";
|
import { ref, reactive, computed } from "vue";
|
||||||
import { onClickOutside, useResizeObserver } from "@vueuse/core";
|
import { useResizeObserver } from "@vueuse/core";
|
||||||
import type { FormInstance, PopoverProps, TableInstance } from "element-plus";
|
import type { FormInstance, PopoverProps, TableInstance } from "element-plus";
|
||||||
|
|
||||||
// 对象类型
|
// 对象类型
|
||||||
@@ -157,7 +157,7 @@ export interface ISelectConfig<T = any> {
|
|||||||
// popover组件属性
|
// popover组件属性
|
||||||
popover?: Partial<Omit<PopoverProps, "visible" | "v-model:visible">>;
|
popover?: Partial<Omit<PopoverProps, "visible" | "v-model:visible">>;
|
||||||
// 列表的网络请求函数(需返回promise)
|
// 列表的网络请求函数(需返回promise)
|
||||||
indexAction: (queryParams: T) => Promise<any>;
|
indexAction: (_queryParams: T) => Promise<any>;
|
||||||
// 主键名(跨页选择必填,默认为id)
|
// 主键名(跨页选择必填,默认为id)
|
||||||
pk?: string;
|
pk?: string;
|
||||||
// 多选
|
// 多选
|
||||||
@@ -284,7 +284,7 @@ const selectedItems = ref<IObject[]>([]);
|
|||||||
const confirmText = computed(() => {
|
const confirmText = computed(() => {
|
||||||
return selectedItems.value.length > 0 ? `已选(${selectedItems.value.length})` : "确 定";
|
return selectedItems.value.length > 0 ? `已选(${selectedItems.value.length})` : "确 定";
|
||||||
});
|
});
|
||||||
function handleSelect(selection: any[], row: any) {
|
function handleSelect(selection: any[], _row: any) {
|
||||||
if (isMultiple || selection.length === 0) {
|
if (isMultiple || selection.length === 0) {
|
||||||
// 多选
|
// 多选
|
||||||
selectedItems.value = selection;
|
selectedItems.value = selection;
|
||||||
|
|||||||
@@ -3,53 +3,41 @@
|
|||||||
<div>
|
<div>
|
||||||
<el-upload
|
<el-upload
|
||||||
v-model:file-list="fileList"
|
v-model:file-list="fileList"
|
||||||
:class="props.showUploadBtn ? 'show-upload-btn' : 'hide-upload-btn'"
|
|
||||||
:style="props.style"
|
:style="props.style"
|
||||||
multiple
|
|
||||||
:headers="props.headers"
|
|
||||||
:data="props.data"
|
|
||||||
:name="props.name"
|
|
||||||
:before-upload="handleBeforeUpload"
|
:before-upload="handleBeforeUpload"
|
||||||
:on-remove="handleRemove"
|
:http-request="handleUpload"
|
||||||
:on-progress="handleProgress"
|
:on-progress="handleProgress"
|
||||||
:on-success="handleSuccessFile"
|
:on-success="handleSuccess"
|
||||||
:on-error="handleError"
|
:on-error="handleError"
|
||||||
:action="props.action"
|
|
||||||
:accept="props.accept"
|
:accept="props.accept"
|
||||||
:limit="props.limit"
|
:limit="props.limit"
|
||||||
|
multiple
|
||||||
>
|
>
|
||||||
<el-button
|
<!-- 上传文件按钮 -->
|
||||||
v-if="props.showUploadBtn"
|
<el-button type="primary" :disabled="fileList.length >= props.limit">
|
||||||
type="primary"
|
|
||||||
:disabled="fileList.length >= props.limit"
|
|
||||||
>
|
|
||||||
{{ props.uploadBtnText }}
|
{{ props.uploadBtnText }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<template v-if="props.showTip" #tip>
|
|
||||||
<div class="el-upload__tip">
|
<!-- 文件列表 -->
|
||||||
{{ props.tip }}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #file="{ file }">
|
<template #file="{ file }">
|
||||||
<div class="el-upload-list__item-info">
|
<div class="el-upload-list__item-info">
|
||||||
<a class="el-upload-list__item-name" @click="downloadFile(file)">
|
<a class="el-upload-list__item-name" @click="handleDownload(file)">
|
||||||
<el-icon><Document /></el-icon>
|
<el-icon><Document /></el-icon>
|
||||||
<span class="el-upload-list__item-file-name">{{ file.name }}</span>
|
<span class="el-upload-list__item-file-name">{{ file.name }}</span>
|
||||||
<span v-if="props.showDelBtn" class="el-icon--close" @click.stop="handleRemove(file)">
|
<span class="el-icon--close" @click="handleRemove(file.url!)">
|
||||||
<el-icon><Close /></el-icon>
|
<el-icon><Close /></el-icon>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-upload>
|
</el-upload>
|
||||||
|
|
||||||
<el-progress
|
<el-progress
|
||||||
v-if="showUploadPercent"
|
|
||||||
:style="{
|
:style="{
|
||||||
display: showUploadPercent ? 'inline-flex' : 'none',
|
display: showProgress ? 'inline-flex' : 'none',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
}"
|
}"
|
||||||
:percentage="uploadPercent"
|
:percentage="progressPercent"
|
||||||
:color="customColorMethod"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -57,98 +45,13 @@
|
|||||||
import {
|
import {
|
||||||
UploadRawFile,
|
UploadRawFile,
|
||||||
UploadUserFile,
|
UploadUserFile,
|
||||||
UploadFile,
|
|
||||||
UploadProgressEvent,
|
UploadProgressEvent,
|
||||||
UploadFiles,
|
UploadRequestOptions,
|
||||||
} from "element-plus";
|
} from "element-plus";
|
||||||
|
|
||||||
import FileAPI from "@/api/file";
|
import FileAPI, { FileInfo } from "@/api/file";
|
||||||
import { getToken } from "@/utils/auth";
|
|
||||||
import { ResultEnum } from "@/enums/ResultEnum";
|
|
||||||
|
|
||||||
const emit = defineEmits(["update:modelValue"]);
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
/**
|
|
||||||
* 文件集合
|
|
||||||
*/
|
|
||||||
modelValue: {
|
|
||||||
type: Array<UploadUserFile>,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* 上传地址
|
|
||||||
*/
|
|
||||||
action: {
|
|
||||||
type: String,
|
|
||||||
default: FileAPI.uploadUrl,
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* 文件上传数量限制
|
|
||||||
*/
|
|
||||||
limit: {
|
|
||||||
type: Number,
|
|
||||||
default: 10,
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* 是否显示删除按钮
|
|
||||||
*/
|
|
||||||
showDelBtn: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* 是否显示上传按钮
|
|
||||||
*/
|
|
||||||
showUploadBtn: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* 单个文件上传大小限制(单位MB)
|
|
||||||
*/
|
|
||||||
maxSize: {
|
|
||||||
type: Number,
|
|
||||||
default: 2 * 1024 * 1024,
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* 上传文件类型
|
|
||||||
*/
|
|
||||||
accept: {
|
|
||||||
type: String,
|
|
||||||
default: "*",
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* 上传按钮文本
|
|
||||||
*/
|
|
||||||
uploadBtnText: {
|
|
||||||
type: String,
|
|
||||||
default: "上传文件",
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* 是否展示提示信息
|
|
||||||
*/
|
|
||||||
showTip: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* 提示信息内容
|
|
||||||
*/
|
|
||||||
tip: {
|
|
||||||
type: String,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* 请求头
|
|
||||||
*/
|
|
||||||
headers: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {
|
|
||||||
return {
|
|
||||||
Authorization: getToken(),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
/**
|
/**
|
||||||
* 请求携带的额外参数
|
* 请求携带的额外参数
|
||||||
*/
|
*/
|
||||||
@@ -165,6 +68,35 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: "file",
|
default: "file",
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* 文件上传数量限制
|
||||||
|
*/
|
||||||
|
limit: {
|
||||||
|
type: Number,
|
||||||
|
default: 10,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 单个文件上传大小限制(单位MB)
|
||||||
|
*/
|
||||||
|
maxFileSize: {
|
||||||
|
type: Number,
|
||||||
|
default: 10,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 上传文件类型
|
||||||
|
*/
|
||||||
|
accept: {
|
||||||
|
type: String,
|
||||||
|
default: "*",
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 上传按钮文本
|
||||||
|
*/
|
||||||
|
uploadBtnText: {
|
||||||
|
type: String,
|
||||||
|
default: "上传文件",
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 样式
|
* 样式
|
||||||
*/
|
*/
|
||||||
@@ -178,115 +110,112 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const modelValue = defineModel("modelValue", {
|
||||||
|
type: [Array] as PropType<string[]>,
|
||||||
|
required: true,
|
||||||
|
default: () => [],
|
||||||
|
});
|
||||||
|
|
||||||
const fileList = ref([] as UploadUserFile[]);
|
const fileList = ref([] as UploadUserFile[]);
|
||||||
const valFileList = ref([] as UploadUserFile[]);
|
|
||||||
const showUploadPercent = ref(false);
|
|
||||||
const uploadPercent = ref(0);
|
|
||||||
|
|
||||||
|
const showProgress = ref(false);
|
||||||
|
const progressPercent = ref(0);
|
||||||
|
|
||||||
|
// 监听 modelValue 转换用于显示的 fileList
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
modelValue,
|
||||||
(newVal: UploadUserFile[]) => {
|
(value) => {
|
||||||
const filePaths = fileList.value.map((file) => file.url);
|
fileList.value = value.map((url) => {
|
||||||
const fileNames = fileList.value.map((file) => file.name);
|
const name = url.substring(url.lastIndexOf("/") + 1);
|
||||||
// 监听modelValue文件集合值未变化时,跳过赋值
|
return {
|
||||||
if (
|
name: name,
|
||||||
filePaths.length > 0 &&
|
url: url,
|
||||||
filePaths.length === newVal.length &&
|
} as UploadUserFile;
|
||||||
filePaths.every((x) => newVal.some((y) => y.url === x)) &&
|
|
||||||
newVal.every((y) => filePaths.some((x) => x === y.url)) &&
|
|
||||||
fileNames.every((x) => newVal.some((y) => y.name === x)) &&
|
|
||||||
newVal.every((y) => fileNames.some((x) => x === y.name))
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newVal.length <= 0) {
|
|
||||||
fileList.value = [];
|
|
||||||
valFileList.value = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fileList.value = newVal.map((file) => {
|
|
||||||
return { name: file.name, url: file.url } as UploadFile;
|
|
||||||
});
|
|
||||||
|
|
||||||
valFileList.value = newVal.map((file) => {
|
|
||||||
return { name: file.name, url: file.url } as UploadFile;
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{
|
||||||
|
immediate: true,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 限制用户上传文件的大小
|
* 上传前校验
|
||||||
*/
|
*/
|
||||||
function handleBeforeUpload(file: UploadRawFile) {
|
function handleBeforeUpload(file: UploadRawFile) {
|
||||||
if (file.size > props.maxSize) {
|
// 限制文件大小
|
||||||
ElMessage.warning("上传文件不能大于" + props.maxSize + "M");
|
if (file.size > props.maxFileSize * 1024 * 1024) {
|
||||||
|
ElMessage.warning("上传图片不能大于" + props.maxFileSize + "M");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
uploadPercent.value = 0;
|
|
||||||
showUploadPercent.value = true;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSuccessFile = (response: any, file: UploadFile) => {
|
/*
|
||||||
showUploadPercent.value = false;
|
* 上传文件
|
||||||
uploadPercent.value = 0;
|
*/
|
||||||
if (response.code === ResultEnum.SUCCESS) {
|
function handleUpload(options: UploadRequestOptions) {
|
||||||
ElMessage.success("上传成功");
|
return new Promise((resolve, reject) => {
|
||||||
valFileList.value.push({
|
const file = options.file;
|
||||||
name: file.name,
|
|
||||||
url: response.data.url,
|
const formData = new FormData();
|
||||||
|
formData.append(props.name, file);
|
||||||
|
|
||||||
|
// 处理附加参数
|
||||||
|
Object.keys(props.data).forEach((key) => {
|
||||||
|
formData.append(key, props.data[key]);
|
||||||
});
|
});
|
||||||
emit("update:modelValue", valFileList.value);
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
ElMessage.error(response.msg || "上传失败");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleError = (error: any) => {
|
FileAPI.upload(formData)
|
||||||
showUploadPercent.value = false;
|
.then((data) => {
|
||||||
uploadPercent.value = 0;
|
resolve(data);
|
||||||
ElMessage.error("上传失败");
|
})
|
||||||
};
|
.catch((error) => {
|
||||||
|
reject(error);
|
||||||
const customColorMethod = (percentage: number) => {
|
});
|
||||||
if (percentage < 30) {
|
});
|
||||||
return "#909399";
|
}
|
||||||
}
|
|
||||||
if (percentage < 70) {
|
|
||||||
return "#375ee8";
|
|
||||||
}
|
|
||||||
return "#67c23a";
|
|
||||||
};
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传进度
|
||||||
|
*
|
||||||
|
* @param event
|
||||||
|
*/
|
||||||
const handleProgress = (event: UploadProgressEvent) => {
|
const handleProgress = (event: UploadProgressEvent) => {
|
||||||
uploadPercent.value = event.percent;
|
progressPercent.value = event.percent;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传成功
|
||||||
|
*/
|
||||||
|
const handleSuccess = (fileInfo: FileInfo) => {
|
||||||
|
ElMessage.success("上传成功");
|
||||||
|
modelValue.value = [...modelValue.value, fileInfo.url];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传失败
|
||||||
|
*/
|
||||||
|
const handleError = (_error: any) => {
|
||||||
|
console.error(_error);
|
||||||
|
ElMessage.error("上传失败");
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除文件
|
* 删除文件
|
||||||
*/
|
*/
|
||||||
function handleRemove(removeFile: UploadUserFile) {
|
function handleRemove(fileUrl: string) {
|
||||||
const filePath = removeFile.url;
|
FileAPI.delete(fileUrl).then(() => {
|
||||||
if (filePath) {
|
modelValue.value = modelValue.value.filter((url) => url !== fileUrl);
|
||||||
FileAPI.deleteByPath(filePath).then(() => {
|
});
|
||||||
// 删除成功回调
|
|
||||||
valFileList.value = valFileList.value.filter((file) => file.url !== filePath);
|
|
||||||
emit("update:modelValue", valFileList.value);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 下载文件
|
* 下载文件
|
||||||
*/
|
*/
|
||||||
function downloadFile(file: UploadUserFile) {
|
function handleDownload(file: UploadUserFile) {
|
||||||
const filePath = file.url;
|
const { url, name } = file;
|
||||||
if (filePath) {
|
if (url) {
|
||||||
FileAPI.downloadFile(filePath, file.name);
|
FileAPI.download(url, name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -298,8 +227,8 @@ function downloadFile(file: UploadUserFile) {
|
|||||||
color: var(--el-text-color-regular);
|
color: var(--el-text-color-regular);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
transition: opacity var(--el-transition-duration);
|
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
|
transition: opacity var(--el-transition-duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-upload-list) {
|
:deep(.el-upload-list) {
|
||||||
@@ -309,16 +238,4 @@ function downloadFile(file: UploadUserFile) {
|
|||||||
:deep(.el-upload-list__item) {
|
:deep(.el-upload-list__item) {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.show-upload-btn {
|
|
||||||
:deep(.el-upload) {
|
|
||||||
display: inline-flex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hide-upload-btn {
|
|
||||||
:deep(.el-upload) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,406 +0,0 @@
|
|||||||
<!-- 图片上传组件 -->
|
|
||||||
<template>
|
|
||||||
<!-- 实际的上传组件(隐藏) -->
|
|
||||||
<div style="display: none">
|
|
||||||
<el-upload
|
|
||||||
ref="uploadRef"
|
|
||||||
v-model:file-list="fileList"
|
|
||||||
:before-upload="handleBeforeUpload"
|
|
||||||
:action="props.action"
|
|
||||||
:headers="props.headers"
|
|
||||||
:data="props.data"
|
|
||||||
:name="props.name"
|
|
||||||
:on-success="handleSuccessFile"
|
|
||||||
:on-error="handleError"
|
|
||||||
:accept="props.accept"
|
|
||||||
:limit="props.limit"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 自定义的显示区域 -->
|
|
||||||
<div class="custom-upload-list">
|
|
||||||
<!-- 已上传的图片列表 -->
|
|
||||||
<div v-for="(path, index) in valFileList" :key="index" class="custom-upload-item">
|
|
||||||
<img class="upload-thumbnail" :src="path" alt="" />
|
|
||||||
<div class="upload-actions">
|
|
||||||
<span class="action-item preview" @click="previewImg(path)">
|
|
||||||
<el-icon><zoom-in /></el-icon>
|
|
||||||
</span>
|
|
||||||
<span v-if="props.showDelBtn" class="action-item delete" @click="handleRemove(path)">
|
|
||||||
<el-icon><Delete /></el-icon>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 上传按钮 -->
|
|
||||||
<div
|
|
||||||
v-if="valFileList.length < props.limit && props.showUploadBtn"
|
|
||||||
class="custom-upload-trigger"
|
|
||||||
@click="triggerUpload"
|
|
||||||
>
|
|
||||||
<el-icon><Plus /></el-icon>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 图片预览组件 -->
|
|
||||||
<el-image-viewer
|
|
||||||
v-if="viewVisible"
|
|
||||||
:zoom-rate="1.2"
|
|
||||||
:initialIndex="initialIndex"
|
|
||||||
:url-list="viewFileList"
|
|
||||||
@close="closePreview"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { UploadRawFile, UploadUserFile, UploadFile } from "element-plus";
|
|
||||||
import FileAPI from "@/api/file";
|
|
||||||
import { getToken } from "@/utils/auth";
|
|
||||||
import { ResultEnum } from "@/enums/ResultEnum";
|
|
||||||
|
|
||||||
const emit = defineEmits(["update:modelValue", "change"]);
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
/**
|
|
||||||
* 文件路径集合
|
|
||||||
*/
|
|
||||||
modelValue: {
|
|
||||||
type: [Array, String],
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* 上传地址
|
|
||||||
*/
|
|
||||||
action: {
|
|
||||||
type: String,
|
|
||||||
default: FileAPI.uploadUrl,
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* 请求头
|
|
||||||
*/
|
|
||||||
headers: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {
|
|
||||||
return {
|
|
||||||
Authorization: getToken(),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* 请求携带的额外参数
|
|
||||||
*/
|
|
||||||
data: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* 上传文件的参数名
|
|
||||||
*/
|
|
||||||
name: {
|
|
||||||
type: String,
|
|
||||||
default: "file",
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* 文件上传数量限制
|
|
||||||
*/
|
|
||||||
limit: {
|
|
||||||
type: Number,
|
|
||||||
default: 1,
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* 是否显示删除按钮
|
|
||||||
*/
|
|
||||||
showDelBtn: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* 是否显示上传按钮
|
|
||||||
*/
|
|
||||||
showUploadBtn: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* 单张图片最大大小,单位MB
|
|
||||||
*/
|
|
||||||
maxSize: {
|
|
||||||
type: Number,
|
|
||||||
default: 10,
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* 上传文件类型
|
|
||||||
*/
|
|
||||||
accept: {
|
|
||||||
type: String,
|
|
||||||
default: "image/*",
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* 支持的文件类型,默认支持所有图片格式
|
|
||||||
* eg:['png','jpg','jpeg','gif']
|
|
||||||
*/
|
|
||||||
supportFileType: {
|
|
||||||
type: Array<string>,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否同步删除
|
|
||||||
*/
|
|
||||||
isSyncDelete: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* 自定义样式
|
|
||||||
*/
|
|
||||||
style: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {
|
|
||||||
return {
|
|
||||||
width: "130px",
|
|
||||||
height: "130px",
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const viewVisible = ref(false);
|
|
||||||
const initialIndex = ref(0);
|
|
||||||
|
|
||||||
const fileList = ref([] as UploadUserFile[]);
|
|
||||||
const valFileList = ref([] as string[]);
|
|
||||||
const viewFileList = ref([] as string[]);
|
|
||||||
|
|
||||||
// 添加一个ref来引用el-upload组件
|
|
||||||
const uploadRef = ref();
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
(newVal) => {
|
|
||||||
if (typeof newVal === "string" && !newVal) {
|
|
||||||
fileList.value = [];
|
|
||||||
viewFileList.value = [];
|
|
||||||
valFileList.value = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const modelValue = typeof newVal === "string" ? [newVal] : (newVal as string[]);
|
|
||||||
const filePaths = fileList.value.map((file) => file.url);
|
|
||||||
// 监听modelValue文件集合值未变化时,跳过赋值
|
|
||||||
if (
|
|
||||||
filePaths.length > 0 &&
|
|
||||||
filePaths.length === modelValue.length &&
|
|
||||||
filePaths.every((x) => modelValue.some((y) => y === x)) &&
|
|
||||||
modelValue.every((y) => filePaths.some((x) => x === y))
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!modelValue || modelValue.length <= 0) {
|
|
||||||
fileList.value = [];
|
|
||||||
viewFileList.value = [];
|
|
||||||
valFileList.value = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fileList.value = modelValue.map((filePath) => {
|
|
||||||
return { url: filePath } as UploadUserFile;
|
|
||||||
});
|
|
||||||
valFileList.value = modelValue;
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 上传成功回调
|
|
||||||
*
|
|
||||||
* @param options
|
|
||||||
*/
|
|
||||||
const handleSuccessFile = (response: any, file: UploadFile) => {
|
|
||||||
if (response.code === ResultEnum.SUCCESS) {
|
|
||||||
ElMessage.success("上传成功");
|
|
||||||
valFileList.value.push(response.data.url);
|
|
||||||
if (props.limit === 1) {
|
|
||||||
emit("update:modelValue", response.data.url);
|
|
||||||
emit("change", response.data.url);
|
|
||||||
} else {
|
|
||||||
emit("update:modelValue", valFileList.value);
|
|
||||||
emit("change", valFileList.value);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
ElMessage.error(response.msg || "上传失败");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleError = (error: any) => {
|
|
||||||
ElMessage.error("上传失败");
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除图片
|
|
||||||
*/
|
|
||||||
function handleRemove(path: string) {
|
|
||||||
if (path) {
|
|
||||||
if (props.isSyncDelete) {
|
|
||||||
FileAPI.deleteByPath(path).then(() => {
|
|
||||||
valFileList.value = valFileList.value.filter((x) => x !== path);
|
|
||||||
// 删除成功回调
|
|
||||||
if (props.limit === 1) {
|
|
||||||
emit("update:modelValue", "");
|
|
||||||
emit("change", "");
|
|
||||||
} else {
|
|
||||||
emit("update:modelValue", valFileList.value);
|
|
||||||
emit("change", valFileList.value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
valFileList.value = valFileList.value.filter((x) => x !== path);
|
|
||||||
// 删除成功回调
|
|
||||||
if (props.limit === 1) {
|
|
||||||
emit("update:modelValue", "");
|
|
||||||
emit("change", "");
|
|
||||||
} else {
|
|
||||||
emit("update:modelValue", valFileList.value);
|
|
||||||
emit("change", valFileList.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 限制用户上传文件的格式和大小
|
|
||||||
*/
|
|
||||||
function handleBeforeUpload(file: UploadRawFile) {
|
|
||||||
// 限制文件大小
|
|
||||||
if (file.size > props.maxSize * 1024 * 1024) {
|
|
||||||
ElMessage.warning("上传图片不能大于" + props.maxSize + "M");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// 判断文件类型
|
|
||||||
// 获取文件后缀名
|
|
||||||
const fileExt = file.name.split(".").pop();
|
|
||||||
if (!fileExt) {
|
|
||||||
ElMessage.warning("上传图片格式错误,支持的文件类型:" + props.supportFileType.join(","));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// 给文件后缀名转换为小写
|
|
||||||
const lowerCaseFileExt = fileExt.toLowerCase();
|
|
||||||
// 判断文件后缀名是否在支持的文件类型中
|
|
||||||
if (props.supportFileType.length > 0) {
|
|
||||||
let isSupport = false;
|
|
||||||
props.supportFileType.forEach((type) => {
|
|
||||||
if (type.toLowerCase() === lowerCaseFileExt) {
|
|
||||||
isSupport = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!isSupport) {
|
|
||||||
ElMessage.warning("上传图片格式错误,支持的文件类型:" + props.supportFileType.join(","));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 预览图片
|
|
||||||
*/
|
|
||||||
const previewImg = (path: string) => {
|
|
||||||
viewFileList.value = fileList.value.map((file) => file.url!);
|
|
||||||
initialIndex.value = fileList.value.findIndex((file) => file.url === path);
|
|
||||||
viewVisible.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 关闭预览
|
|
||||||
*/
|
|
||||||
const closePreview = () => {
|
|
||||||
viewVisible.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 修改triggerUpload方法
|
|
||||||
const triggerUpload = () => {
|
|
||||||
// 通过ref直接访问el-upload组件内的input元素
|
|
||||||
const uploadEl = uploadRef.value.$el.querySelector(".el-upload__input");
|
|
||||||
if (uploadEl) {
|
|
||||||
uploadEl.click();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.custom-upload-list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-upload-item {
|
|
||||||
position: relative;
|
|
||||||
width: v-bind("props.style.width");
|
|
||||||
height: v-bind("props.style.height");
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 6px;
|
|
||||||
|
|
||||||
.upload-thumbnail {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-actions {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background-color: rgb(0 0 0 / 50%);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.3s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-item {
|
|
||||||
padding: 8px;
|
|
||||||
color: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--el-color-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-upload-trigger {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: v-bind("props.style.width");
|
|
||||||
height: v-bind("props.style.height");
|
|
||||||
cursor: pointer;
|
|
||||||
background-color: rgb(255 254 254 / 50%);
|
|
||||||
border: 1px dashed var(--el-border-color);
|
|
||||||
border-radius: 6px;
|
|
||||||
transition: var(--el-transition-duration);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--el-color-primary);
|
|
||||||
border-color: var(--el-color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-icon {
|
|
||||||
font-size: 20px;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover .el-icon {
|
|
||||||
color: var(--el-color-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
216
src/components/Upload/MultiImageUpload.vue
Normal file
216
src/components/Upload/MultiImageUpload.vue
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
<!-- 图片上传组件 -->
|
||||||
|
<template>
|
||||||
|
<el-upload
|
||||||
|
v-model:file-list="fileList"
|
||||||
|
list-type="picture-card"
|
||||||
|
:before-upload="handleBeforeUpload"
|
||||||
|
:http-request="handleUpload"
|
||||||
|
:on-success="handleSuccess"
|
||||||
|
:on-error="handleError"
|
||||||
|
:on-exceed="handleExceed"
|
||||||
|
:accept="props.accept"
|
||||||
|
:limit="props.limit"
|
||||||
|
multiple
|
||||||
|
>
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
<template #file="{ file }">
|
||||||
|
<div style="width: 100%">
|
||||||
|
<img class="el-upload-list__item-thumbnail" :src="file.url" />
|
||||||
|
<span class="el-upload-list__item-actions">
|
||||||
|
<!-- 预览 -->
|
||||||
|
<span @click="handlePreviewImage(file.url!)">
|
||||||
|
<el-icon><zoom-in /></el-icon>
|
||||||
|
</span>
|
||||||
|
<!-- 删除 -->
|
||||||
|
<span @click="handleRemove(file.url!)">
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-upload>
|
||||||
|
|
||||||
|
<el-image-viewer
|
||||||
|
v-if="previewVisible"
|
||||||
|
:zoom-rate="1.2"
|
||||||
|
:initial-index="previewImageIndex"
|
||||||
|
:url-list="modelValue"
|
||||||
|
@close="handlePreviewClose"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { UploadRawFile, UploadRequestOptions, UploadUserFile } from "element-plus";
|
||||||
|
import FileAPI, { FileInfo } from "@/api/file";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
/**
|
||||||
|
* 请求携带的额外参数
|
||||||
|
*/
|
||||||
|
data: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 上传文件的参数名
|
||||||
|
*/
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
default: "file",
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 文件上传数量限制
|
||||||
|
*/
|
||||||
|
limit: {
|
||||||
|
type: Number,
|
||||||
|
default: 10,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 单个文件的最大允许大小
|
||||||
|
*/
|
||||||
|
maxFileSize: {
|
||||||
|
type: Number,
|
||||||
|
default: 10,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 上传文件类型
|
||||||
|
*/
|
||||||
|
accept: {
|
||||||
|
type: String,
|
||||||
|
default: "image/*", // 默认支持所有图片格式 ,如果需要指定格式,格式如下:'.png,.jpg,.jpeg,.gif,.bmp'
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewVisible = ref(false); // 是否显示预览
|
||||||
|
const previewImageIndex = ref(0); // 预览图片的索引
|
||||||
|
|
||||||
|
const modelValue = defineModel("modelValue", {
|
||||||
|
type: [Array] as PropType<string[]>,
|
||||||
|
required: true,
|
||||||
|
default: () => [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileList = ref<UploadUserFile[]>([]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除图片
|
||||||
|
*/
|
||||||
|
function handleRemove(imageUrl: string) {
|
||||||
|
FileAPI.delete(imageUrl).then(() => {
|
||||||
|
const index = modelValue.value.indexOf(imageUrl);
|
||||||
|
if (index !== -1) {
|
||||||
|
// 直接修改数组避免触发整体更新
|
||||||
|
modelValue.value.splice(index, 1);
|
||||||
|
fileList.value.splice(index, 1); // 同步更新 fileList
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传前校验
|
||||||
|
*/
|
||||||
|
function handleBeforeUpload(file: UploadRawFile) {
|
||||||
|
// 校验文件类型:虽然 accept 属性限制了用户在文件选择器中可选的文件类型,但仍需在上传时再次校验文件实际类型,确保符合 accept 的规则
|
||||||
|
const acceptTypes = props.accept.split(",").map((type) => type.trim());
|
||||||
|
|
||||||
|
// 检查文件格式是否符合 accept
|
||||||
|
const isValidType = acceptTypes.some((type) => {
|
||||||
|
if (type === "image/*") {
|
||||||
|
// 如果是 image/*,检查 MIME 类型是否以 "image/" 开头
|
||||||
|
return file.type.startsWith("image/");
|
||||||
|
} else if (type.startsWith(".")) {
|
||||||
|
// 如果是扩展名 (.png, .jpg),检查文件名是否以指定扩展名结尾
|
||||||
|
return file.name.toLowerCase().endsWith(type);
|
||||||
|
} else {
|
||||||
|
// 如果是具体的 MIME 类型 (image/png, image/jpeg),检查是否完全匹配
|
||||||
|
return file.type === type;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isValidType) {
|
||||||
|
ElMessage.warning(`上传文件的格式不正确,仅支持:${props.accept}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制文件大小
|
||||||
|
if (file.size > props.maxFileSize * 1024 * 1024) {
|
||||||
|
ElMessage.warning("上传图片不能大于" + props.maxFileSize + "M");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 上传文件
|
||||||
|
*/
|
||||||
|
function handleUpload(options: UploadRequestOptions) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const file = options.file;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append(props.name, file);
|
||||||
|
|
||||||
|
// 处理附加参数
|
||||||
|
Object.keys(props.data).forEach((key) => {
|
||||||
|
formData.append(key, props.data[key]);
|
||||||
|
});
|
||||||
|
|
||||||
|
FileAPI.upload(formData)
|
||||||
|
.then((data) => {
|
||||||
|
resolve(data);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文件超出限制
|
||||||
|
*/
|
||||||
|
function handleExceed() {
|
||||||
|
ElMessage.warning("最多只能上传" + props.limit + "张图片");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传成功回调
|
||||||
|
*/
|
||||||
|
const handleSuccess = (fileInfo: FileInfo, uploadFile: UploadUserFile) => {
|
||||||
|
ElMessage.success("上传成功");
|
||||||
|
const index = fileList.value.findIndex((file) => file.uid === uploadFile.uid);
|
||||||
|
if (index !== -1) {
|
||||||
|
fileList.value[index].url = fileInfo.url;
|
||||||
|
fileList.value[index].status = "success";
|
||||||
|
modelValue.value[index] = fileInfo.url;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传失败回调
|
||||||
|
*/
|
||||||
|
const handleError = (error: any) => {
|
||||||
|
console.log("handleError");
|
||||||
|
ElMessage.error("上传失败: " + error.message);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预览图片
|
||||||
|
*/
|
||||||
|
const handlePreviewImage = (imageUrl: string) => {
|
||||||
|
previewImageIndex.value = modelValue.value.findIndex((url) => url === imageUrl);
|
||||||
|
previewVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭预览
|
||||||
|
*/
|
||||||
|
const handlePreviewClose = () => {
|
||||||
|
previewVisible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fileList.value = modelValue.value.map((url) => ({ url }) as UploadUserFile);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
203
src/components/Upload/SingleImageUpload.vue
Normal file
203
src/components/Upload/SingleImageUpload.vue
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
<!-- 单图上传组件 -->
|
||||||
|
<template>
|
||||||
|
<el-upload
|
||||||
|
v-model="modelValue"
|
||||||
|
class="single-upload"
|
||||||
|
list-type="picture-card"
|
||||||
|
:show-file-list="false"
|
||||||
|
:accept="props.accept"
|
||||||
|
:before-upload="handleBeforeUpload"
|
||||||
|
:http-request="handleUpload"
|
||||||
|
:on-success="onSuccess"
|
||||||
|
:on-error="onError"
|
||||||
|
multiple
|
||||||
|
>
|
||||||
|
<template #default>
|
||||||
|
<el-image v-if="modelValue" :src="modelValue" />
|
||||||
|
<el-icon v-if="modelValue" class="single-upload__delete-btn" @click.stop="handleDelete">
|
||||||
|
<CircleCloseFilled />
|
||||||
|
</el-icon>
|
||||||
|
<el-icon v-else class="single-upload__add-btn">
|
||||||
|
<Plus />
|
||||||
|
</el-icon>
|
||||||
|
</template>
|
||||||
|
</el-upload>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { UploadRawFile, UploadRequestOptions } from "element-plus";
|
||||||
|
import FileAPI, { FileInfo } from "@/api/file";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
/**
|
||||||
|
* 请求携带的额外参数
|
||||||
|
*/
|
||||||
|
data: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 上传文件的参数名
|
||||||
|
*/
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
default: "file",
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 最大文件大小(单位:M)
|
||||||
|
*/
|
||||||
|
maxFileSize: {
|
||||||
|
type: Number,
|
||||||
|
default: 10,
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传图片格式,默认支持所有图片(image/*),指定格式示例:'.png,.jpg,.jpeg,.gif,.bmp'
|
||||||
|
*/
|
||||||
|
accept: {
|
||||||
|
type: String,
|
||||||
|
default: "image/*",
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义样式,用于设置组件的宽度和高度等其他样式
|
||||||
|
*/
|
||||||
|
style: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {
|
||||||
|
return {
|
||||||
|
width: "150px",
|
||||||
|
height: "150px",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const modelValue = defineModel("modelValue", {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
default: () => "",
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 限制用户上传文件的格式和大小
|
||||||
|
*/
|
||||||
|
function handleBeforeUpload(file: UploadRawFile) {
|
||||||
|
// 校验文件类型:虽然 accept 属性限制了用户在文件选择器中可选的文件类型,但仍需在上传时再次校验文件实际类型,确保符合 accept 的规则
|
||||||
|
const acceptTypes = props.accept.split(",").map((type) => type.trim());
|
||||||
|
|
||||||
|
// 检查文件格式是否符合 accept
|
||||||
|
const isValidType = acceptTypes.some((type) => {
|
||||||
|
if (type === "image/*") {
|
||||||
|
// 如果是 image/*,检查 MIME 类型是否以 "image/" 开头
|
||||||
|
return file.type.startsWith("image/");
|
||||||
|
} else if (type.startsWith(".")) {
|
||||||
|
// 如果是扩展名 (.png, .jpg),检查文件名是否以指定扩展名结尾
|
||||||
|
return file.name.toLowerCase().endsWith(type);
|
||||||
|
} else {
|
||||||
|
// 如果是具体的 MIME 类型 (image/png, image/jpeg),检查是否完全匹配
|
||||||
|
return file.type === type;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isValidType) {
|
||||||
|
ElMessage.warning(`上传文件的格式不正确,仅支持:${props.accept}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制文件大小
|
||||||
|
if (file.size > props.maxFileSize * 1024 * 1024) {
|
||||||
|
ElMessage.warning("上传图片不能大于" + props.maxFileSize + "M");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 上传图片
|
||||||
|
*/
|
||||||
|
function handleUpload(options: UploadRequestOptions) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const file = options.file;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append(props.name, file);
|
||||||
|
|
||||||
|
// 处理附加参数
|
||||||
|
Object.keys(props.data).forEach((key) => {
|
||||||
|
formData.append(key, props.data[key]);
|
||||||
|
});
|
||||||
|
|
||||||
|
FileAPI.upload(formData)
|
||||||
|
.then((data) => {
|
||||||
|
resolve(data);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除图片
|
||||||
|
*/
|
||||||
|
function handleDelete() {
|
||||||
|
modelValue.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传成功回调
|
||||||
|
*
|
||||||
|
* @param fileInfo 上传成功后的文件信息
|
||||||
|
*/
|
||||||
|
const onSuccess = (fileInfo: FileInfo) => {
|
||||||
|
ElMessage.success("上传成功");
|
||||||
|
modelValue.value = fileInfo.url;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传失败回调
|
||||||
|
*/
|
||||||
|
const onError = (error: any) => {
|
||||||
|
console.log("onError");
|
||||||
|
ElMessage.error("上传失败: " + error.message);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
:deep(.el-upload--picture-card) {
|
||||||
|
/* width: var(--el-upload-picture-card-size);
|
||||||
|
height: var(--el-upload-picture-card-size); */
|
||||||
|
width: v-bind("props.style.width");
|
||||||
|
height: v-bind("props.style.height");
|
||||||
|
}
|
||||||
|
|
||||||
|
.single-upload {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px var(--el-border-color) solid;
|
||||||
|
border-radius: 5px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__delete-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 1px;
|
||||||
|
right: 1px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #ff7901;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 100%;
|
||||||
|
|
||||||
|
:hover {
|
||||||
|
color: #ff4500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,80 +1,87 @@
|
|||||||
|
<!--
|
||||||
|
* 基于 wangEditor-next 的富文本编辑器组件二次封装
|
||||||
|
* 版权所有 © 2021-present 有来开源组织
|
||||||
|
*
|
||||||
|
* 开源协议:https://opensource.org/licenses/MIT
|
||||||
|
* 项目地址:https://gitee.com/youlaiorg/vue3-element-admin
|
||||||
|
*
|
||||||
|
* 在使用时,请保留此注释,感谢您对开源的支持!
|
||||||
|
-->
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="editor-wrapper">
|
<div style="z-index: 999; border: 1px solid #ccc">
|
||||||
<!-- 工具栏 -->
|
<!-- 工具栏 -->
|
||||||
<Toolbar
|
<Toolbar
|
||||||
id="toolbar-container"
|
|
||||||
:editor="editorRef"
|
:editor="editorRef"
|
||||||
|
mode="simple"
|
||||||
:default-config="toolbarConfig"
|
:default-config="toolbarConfig"
|
||||||
:mode="mode"
|
style="border-bottom: 1px solid #ccc"
|
||||||
/>
|
/>
|
||||||
<!-- 编辑器 -->
|
<!-- 编辑器 -->
|
||||||
<Editor
|
<Editor
|
||||||
id="editor-container"
|
|
||||||
v-model="modelValue"
|
v-model="modelValue"
|
||||||
|
:style="{ height: height, overflowY: 'hidden' }"
|
||||||
:default-config="editorConfig"
|
:default-config="editorConfig"
|
||||||
:mode="mode"
|
mode="simple"
|
||||||
style="height: 500px; overflow-y: hidden"
|
|
||||||
@on-change="handleChange"
|
|
||||||
@on-created="handleCreated"
|
@on-created="handleCreated"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
|
import "@wangeditor-next/editor/dist/css/style.css";
|
||||||
|
import { Toolbar, Editor } from "@wangeditor-next/editor-for-vue";
|
||||||
|
import { IToolbarConfig, IEditorConfig } from "@wangeditor-next/editor";
|
||||||
|
|
||||||
// API 引用
|
// 文件上传 API
|
||||||
import FileAPI from "@/api/file";
|
import FileAPI from "@/api/file";
|
||||||
|
|
||||||
const props = defineProps({
|
// 上传图片回调函数类型
|
||||||
modelValue: {
|
type InsertFnType = (_url: string, _alt: string, _href: string) => void;
|
||||||
type: [String],
|
|
||||||
default: "",
|
defineProps({
|
||||||
},
|
height: {
|
||||||
excludeKeys: {
|
type: String,
|
||||||
type: Array<string>,
|
default: "500px",
|
||||||
default: [],
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
// 双向绑定
|
||||||
|
const modelValue = defineModel("modelValue", {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
});
|
||||||
|
|
||||||
const emit = defineEmits(["update:modelValue"]);
|
// 编辑器实例,必须用 shallowRef,重要!
|
||||||
|
const editorRef = shallowRef();
|
||||||
|
|
||||||
const modelValue = useVModel(props, "modelValue", emit);
|
// 工具栏配置
|
||||||
|
const toolbarConfig = ref<Partial<IToolbarConfig>>({});
|
||||||
|
|
||||||
const editorRef = shallowRef(); // 编辑器实例,必须用 shallowRef
|
|
||||||
const mode = ref("simple"); // 编辑器模式
|
|
||||||
const toolbarConfig = ref({
|
|
||||||
excludeKeys: props.excludeKeys,
|
|
||||||
}); // 工具条配置
|
|
||||||
// 编辑器配置
|
// 编辑器配置
|
||||||
const editorConfig = ref({
|
const editorConfig = ref<Partial<IEditorConfig>>({
|
||||||
placeholder: "请输入内容...",
|
placeholder: "请输入内容...",
|
||||||
MENU_CONF: {
|
MENU_CONF: {
|
||||||
uploadImage: {
|
uploadImage: {
|
||||||
// 自定义图片上传
|
customUpload(file: File, insertFn: InsertFnType) {
|
||||||
async customUpload(file: any, insertFn: any) {
|
// 上传图片
|
||||||
FileAPI.upload(file).then((data) => {
|
FileAPI.uploadFile(file).then((res) => {
|
||||||
insertFn(data.url);
|
// 插入图片
|
||||||
|
insertFn(res.url, res.name, res.url);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
} as any,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 记录 editor 实例,重要!
|
||||||
const handleCreated = (editor: any) => {
|
const handleCreated = (editor: any) => {
|
||||||
editorRef.value = editor; // 记录 editor 实例,重要!
|
editorRef.value = editor;
|
||||||
};
|
};
|
||||||
|
|
||||||
function handleChange(editor: any) {
|
// 组件销毁时,也及时销毁编辑器,重要!
|
||||||
modelValue.value = editor.getHtml();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 组件销毁时,也及时销毁编辑器
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
const editor = editorRef.value;
|
const editor = editorRef.value;
|
||||||
if (editor == null) return;
|
if (editor == null) return;
|
||||||
editor.destroy();
|
editor.destroy();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style src="@wangeditor/editor/dist/css/style.css"></style>
|
|
||||||
|
|||||||
150
src/hooks/useStomp.ts
Normal file
150
src/hooks/useStomp.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { Client, IMessage, StompSubscription } from "@stomp/stompjs";
|
||||||
|
import { getAccessToken } from "@/utils/auth";
|
||||||
|
|
||||||
|
export interface UseStompOptions {
|
||||||
|
/** WebSocket 地址,不传时使用 VITE_APP_WS_ENDPOINT 环境变量 */
|
||||||
|
brokerURL?: string;
|
||||||
|
/** 用于鉴权的 token,不传时使用 getAccessToken() 的返回值 */
|
||||||
|
token?: string;
|
||||||
|
/** 重连延迟,单位毫秒,默认为 5000 */
|
||||||
|
reconnectDelay?: number;
|
||||||
|
/** 是否开启调试日志 */
|
||||||
|
debug?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStomp(options: UseStompOptions = {}) {
|
||||||
|
// 默认值:brokerURL 从环境变量中获取,token 从 getAccessToken() 获取
|
||||||
|
const defaultBrokerURL = import.meta.env.VITE_APP_WS_ENDPOINT || "";
|
||||||
|
const defaultToken = getAccessToken();
|
||||||
|
|
||||||
|
const brokerURL = ref(options.brokerURL ?? defaultBrokerURL);
|
||||||
|
const token = options.token ?? defaultToken;
|
||||||
|
|
||||||
|
// 连接状态标记
|
||||||
|
const isConnected = ref(false);
|
||||||
|
// 存储所有订阅
|
||||||
|
const subscriptions = new Map<string, StompSubscription>();
|
||||||
|
|
||||||
|
// 用于保存 STOMP 客户端的实例
|
||||||
|
let client = ref<Client | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化 STOMP 客户端
|
||||||
|
* 只有在 brokerURL 非空时才会初始化客户端
|
||||||
|
*/
|
||||||
|
const initializeClient = () => {
|
||||||
|
if (!brokerURL.value) {
|
||||||
|
console.warn(
|
||||||
|
"brokerURL is required. Please set the WebSocket URL in your .env file (VITE_APP_WS_ENDPOINT)."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client.value) {
|
||||||
|
client.value = new Client({
|
||||||
|
brokerURL: brokerURL.value,
|
||||||
|
reconnectDelay: options.reconnectDelay ?? 5000,
|
||||||
|
debug: options.debug ? (msg) => console.log("[STOMP]", msg) : () => {},
|
||||||
|
connectHeaders: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
heartbeatIncoming: 4000,
|
||||||
|
heartbeatOutgoing: 4000,
|
||||||
|
});
|
||||||
|
|
||||||
|
client.value.onConnect = (frame) => {
|
||||||
|
isConnected.value = true;
|
||||||
|
console.log("STOMP connected", frame);
|
||||||
|
};
|
||||||
|
|
||||||
|
client.value.onStompError = (frame) => {
|
||||||
|
console.error("Broker reported error: " + frame.headers["message"]);
|
||||||
|
console.error("Additional details: " + frame.body);
|
||||||
|
};
|
||||||
|
|
||||||
|
client.value.onWebSocketClose = (evt) => {
|
||||||
|
isConnected.value = false;
|
||||||
|
console.warn("WebSocket closed", evt);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听 brokerURL 的变化,若地址改变则重新初始化
|
||||||
|
watch(brokerURL, (newURL, oldURL) => {
|
||||||
|
if (newURL !== oldURL) {
|
||||||
|
console.log(`brokerURL changed from ${oldURL} to ${newURL}`);
|
||||||
|
// 断开当前连接,重新激活客户端
|
||||||
|
if (client.value && client.value.connected) {
|
||||||
|
client.value.deactivate();
|
||||||
|
}
|
||||||
|
brokerURL.value = newURL;
|
||||||
|
initializeClient(); // 重新初始化客户端
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 在组件挂载时检查并初始化客户端
|
||||||
|
onMounted(() => {
|
||||||
|
console.log("useStomp onMounted initializeClient");
|
||||||
|
initializeClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 激活连接(如果已经连接或正在激活则直接返回)
|
||||||
|
*/
|
||||||
|
const connect = () => {
|
||||||
|
if (client.value && (client.value.connected || client.value.active)) {
|
||||||
|
console.log("Already connected or connecting, skipping connect() call.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
client.value?.activate();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅指定主题
|
||||||
|
* @param destination 目标主题地址
|
||||||
|
* @param callback 接收到消息时的回调函数
|
||||||
|
* @returns 返回订阅 id,用于后续取消订阅
|
||||||
|
*/
|
||||||
|
const subscribe = (destination: string, callback: (message: IMessage) => void): string => {
|
||||||
|
if (client.value) {
|
||||||
|
const subscription = client.value.subscribe(destination, callback);
|
||||||
|
subscriptions.set(subscription.id, subscription);
|
||||||
|
return subscription.id;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消指定订阅
|
||||||
|
* @param subscriptionId 要取消的订阅 id
|
||||||
|
*/
|
||||||
|
const unsubscribe = (subscriptionId: string) => {
|
||||||
|
const subscription = subscriptions.get(subscriptionId);
|
||||||
|
if (subscription) {
|
||||||
|
subscription.unsubscribe();
|
||||||
|
subscriptions.delete(subscriptionId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主动断开连接(如果未连接则不执行)
|
||||||
|
*/
|
||||||
|
const disconnect = () => {
|
||||||
|
if (client.value && !(client.value.connected || client.value.active)) {
|
||||||
|
console.log("Already disconnected, skipping disconnect() call.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
client.value?.deactivate();
|
||||||
|
isConnected.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
isConnected,
|
||||||
|
connect,
|
||||||
|
subscribe,
|
||||||
|
unsubscribe,
|
||||||
|
disconnect,
|
||||||
|
brokerURL,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<section class="app-main" :style="{ height: appMainHeight }">
|
<section class="app-main" :style="{ height: appMainHeight }">
|
||||||
<router-view>
|
<router-view>
|
||||||
<template #default="{ Component, route }">
|
<template #default="{ Component, route }">
|
||||||
<transition name="el-fade-in-linear" mode="out-in">
|
<transition enter-active-class="animate__animated animate__fadeIn" mode="out-in">
|
||||||
<keep-alive :include="cachedViews">
|
<keep-alive :include="cachedViews">
|
||||||
<component :is="Component" :key="route.path" />
|
<component :is="Component" :key="route.path" />
|
||||||
</keep-alive>
|
</keep-alive>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="navbar__right" :class="navbarRightClass">
|
<div class="navbar__right" :class="navbarRightClass">
|
||||||
<!-- 非手机设备(窄屏)才显示 -->
|
<!-- 桌面端显示 -->
|
||||||
<template v-if="!isMobile">
|
<template v-if="isDesktop">
|
||||||
<!-- 搜索 -->
|
<!-- 搜索 -->
|
||||||
<MenuSearch />
|
<MenuSearch />
|
||||||
|
|
||||||
@@ -19,28 +19,52 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 用户头像(个人中心、注销登录等) -->
|
<!-- 用户头像(个人中心、注销登录等) -->
|
||||||
<UserProfile />
|
<el-dropdown trigger="click">
|
||||||
|
<div class="user-profile">
|
||||||
|
<img class="user-profile__avatar" :src="userStore.userInfo.avatar" />
|
||||||
|
<span class="user-profile__name">{{ userStore.userInfo.username }}</span>
|
||||||
|
</div>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item @click="handleProfileClick">
|
||||||
|
{{ $t("navbar.profile") }}
|
||||||
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item divided @click="logout">
|
||||||
|
{{ $t("navbar.logout") }}
|
||||||
|
</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
|
||||||
<!-- 设置面板 -->
|
<!-- 设置面板 -->
|
||||||
<div v-if="defaultSettings.showSettings" @click="settingStore.settingsVisible = true">
|
<div v-if="defaultSettings.showSettings" @click="settingStore.settingsVisible = true">
|
||||||
<SvgIcon icon-class="setting" />
|
<div class="i-svg:setting" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import defaultSettings from "@/settings";
|
import defaultSettings from "@/settings";
|
||||||
import { DeviceEnum } from "@/enums/DeviceEnum";
|
import { DeviceEnum } from "@/enums/DeviceEnum";
|
||||||
|
import { useAppStore, useSettingsStore, useUserStore, useTagsViewStore } from "@/store";
|
||||||
|
|
||||||
import { useAppStore, useSettingsStore } from "@/store";
|
|
||||||
|
|
||||||
import UserProfile from "./UserProfile.vue";
|
|
||||||
import Notification from "./Notification.vue";
|
import Notification from "./Notification.vue";
|
||||||
import { SidebarLightThemeEnum } from "@/enums/ThemeEnum";
|
import { SidebarLightThemeEnum } from "@/enums/ThemeEnum";
|
||||||
|
|
||||||
const appStore = useAppStore();
|
const appStore = useAppStore();
|
||||||
const settingStore = useSettingsStore();
|
const settingStore = useSettingsStore();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const tagsViewStore = useTagsViewStore();
|
||||||
|
|
||||||
const isMobile = computed(() => appStore.device === DeviceEnum.MOBILE);
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const isDesktop = computed(() => appStore.device === DeviceEnum.DESKTOP);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开个人中心页面
|
||||||
|
*/
|
||||||
|
function handleProfileClick() {
|
||||||
|
router.push({ name: "Profile" });
|
||||||
|
}
|
||||||
|
|
||||||
// 根据浅色模式-侧边栏颜色配置选择navbar右侧的样式类
|
// 根据浅色模式-侧边栏颜色配置选择navbar右侧的样式类
|
||||||
const navbarRightClass = computed(() => {
|
const navbarRightClass = computed(() => {
|
||||||
@@ -48,6 +72,27 @@ const navbarRightClass = computed(() => {
|
|||||||
? "navbar__right--darkBlue"
|
? "navbar__right--darkBlue"
|
||||||
: "navbar__right--white";
|
: "navbar__right--white";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注销登出
|
||||||
|
*/
|
||||||
|
function logout() {
|
||||||
|
ElMessageBox.confirm("确定注销并退出系统吗?", "提示", {
|
||||||
|
confirmButtonText: "确定",
|
||||||
|
cancelButtonText: "取消",
|
||||||
|
type: "warning",
|
||||||
|
lockScroll: false,
|
||||||
|
}).then(() => {
|
||||||
|
userStore
|
||||||
|
.logout()
|
||||||
|
.then(() => {
|
||||||
|
tagsViewStore.delAllViews();
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
router.push(`/login?redirect=${route.fullPath}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -69,14 +114,23 @@ const navbarRightClass = computed(() => {
|
|||||||
background: rgb(0 0 0 / 10%);
|
background: rgb(0 0 0 / 10%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
.user-profile {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0 13px;
|
||||||
|
|
||||||
:deep(.el-divider--horizontal) {
|
&__avatar {
|
||||||
margin: 10px 0;
|
width: 32px;
|
||||||
}
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
.dark .navbar__right > *:hover {
|
&__name {
|
||||||
background: rgb(255 255 255 / 20%);
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout-top .navbar__right--darkBlue > *,
|
.layout-top .navbar__right--darkBlue > *,
|
||||||
@@ -84,8 +138,13 @@ const navbarRightClass = computed(() => {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.layout-top .navbar__right--white > *,
|
.layout-top .navbar__right--white > *,
|
||||||
.layout-mix .navbar__right--white > * {
|
.layout-mix .navbar__right--white > * {
|
||||||
color: #000;
|
color: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark .navbar__right > *:hover {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,162 +1,61 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<el-dropdown class="wh-full">
|
<el-dropdown class="h-full items-center justify-center" trigger="click">
|
||||||
<el-badge v-if="notices.length > 0" :offset="[-10, 15]" :value="notices.length" :max="99">
|
<el-badge v-if="notices.length > 0" :offset="[0, 15]" :value="notices.length" :max="99">
|
||||||
<el-icon>
|
<el-icon>
|
||||||
<Bell />
|
<Bell />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
</el-badge>
|
</el-badge>
|
||||||
<el-badge v-else>
|
|
||||||
|
<div v-else>
|
||||||
<el-icon>
|
<el-icon>
|
||||||
<Bell />
|
<Bell />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
</el-badge>
|
</div>
|
||||||
|
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<div class="p-2">
|
<div class="p-5">
|
||||||
<el-tabs v-model="activeTab">
|
<template v-if="notices.length > 0">
|
||||||
<el-tab-pane label="通知" name="notice">
|
<div v-for="(item, index) in notices" :key="index" class="w500px py-3">
|
||||||
<template v-if="notices.length > 0">
|
<div class="flex-y-center">
|
||||||
<div v-for="(item, index) in notices" :key="index" class="w500px py-3">
|
<DictLabel v-model="item.type" code="notice_type" size="small" />
|
||||||
<div class="flex-y-center">
|
<el-text
|
||||||
<DictLabel v-model="item.type" code="notice_type" size="small" />
|
size="small"
|
||||||
<el-text
|
class="w200px cursor-pointer !ml-2 !flex-1"
|
||||||
size="small"
|
truncated
|
||||||
class="w200px cursor-pointer !ml-2 !flex-1"
|
@click="readNotice(item.id)"
|
||||||
truncated
|
>
|
||||||
@click="handleReadNotice(item.id)"
|
{{ item.title }}
|
||||||
>
|
</el-text>
|
||||||
{{ item.title }}
|
|
||||||
</el-text>
|
|
||||||
|
|
||||||
<div class="text-xs text-gray">
|
<div class="text-xs text-gray">
|
||||||
{{ item.publishTime }}
|
{{ item.publishTime }}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<div class="flex-center h150px w350px">
|
|
||||||
<el-empty :image-size="50" description="暂无通知" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<el-divider />
|
|
||||||
<div class="flex-x-between">
|
|
||||||
<el-link type="primary" :underline="false" @click="handleViewMore">
|
|
||||||
<span class="text-xs">查看更多</span>
|
|
||||||
<el-icon class="text-xs">
|
|
||||||
<ArrowRight />
|
|
||||||
</el-icon>
|
|
||||||
</el-link>
|
|
||||||
<el-link
|
|
||||||
v-if="notices.length > 0"
|
|
||||||
type="primary"
|
|
||||||
:underline="false"
|
|
||||||
@click="markAllAsRead"
|
|
||||||
>
|
|
||||||
<span class="text-xs">全部已读</span>
|
|
||||||
</el-link>
|
|
||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</div>
|
||||||
<el-tab-pane label="消息" name="message">
|
<el-divider />
|
||||||
<template v-if="messages.length > 0">
|
<div class="flex-x-between">
|
||||||
<div
|
<el-link type="primary" :underline="false" @click="viewMoreNotice">
|
||||||
v-for="(item, index) in messages"
|
<span class="text-xs">查看更多</span>
|
||||||
:key="index"
|
<el-icon class="text-xs">
|
||||||
class="w400px flex-x-between p-1"
|
<ArrowRight />
|
||||||
>
|
</el-icon>
|
||||||
<div class="flex-y-center">
|
</el-link>
|
||||||
<DictLabel v-model="item.type" code="notice_type" size="small" />
|
<el-link
|
||||||
<el-link
|
v-if="notices.length > 0"
|
||||||
type="primary"
|
type="primary"
|
||||||
class="w200px cursor-pointer !ml-2 !flex-1"
|
:underline="false"
|
||||||
@click="handleReadNotice(item.id)"
|
@click="markAllAsRead"
|
||||||
>
|
>
|
||||||
{{ item.title }}
|
<span class="text-xs">全部已读</span>
|
||||||
</el-link>
|
</el-link>
|
||||||
|
</div>
|
||||||
<div class="text-xs text-gray-400">
|
</template>
|
||||||
{{ item.publishTime }}
|
<template v-else>
|
||||||
</div>
|
<div class="flex-center h150px w350px">
|
||||||
</div>
|
<el-empty :image-size="50" description="暂无消息" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
|
||||||
<div class="flex-center h150px w350px">
|
|
||||||
<el-empty :image-size="50" description="暂无消息" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<el-divider />
|
|
||||||
<div class="flex-x-between">
|
|
||||||
<el-link
|
|
||||||
v-if="tasks.length > 5"
|
|
||||||
type="primary"
|
|
||||||
:underline="false"
|
|
||||||
@click="handleViewMore"
|
|
||||||
>
|
|
||||||
<span class="text-xs">查看更多</span>
|
|
||||||
<el-icon class="text-xs">
|
|
||||||
<ArrowRight />
|
|
||||||
</el-icon>
|
|
||||||
</el-link>
|
|
||||||
<el-link
|
|
||||||
v-if="messages.length > 0"
|
|
||||||
type="primary"
|
|
||||||
:underline="false"
|
|
||||||
@click="markAllAsRead"
|
|
||||||
>
|
|
||||||
<span class="text-xs">全部已读</span>
|
|
||||||
</el-link>
|
|
||||||
</div>
|
|
||||||
</el-tab-pane>
|
|
||||||
|
|
||||||
<el-tab-pane label="待办" name="task">
|
|
||||||
<template v-if="tasks.length > 0">
|
|
||||||
<div v-for="(item, index) in tasks" :key="index" class="w500px py-3">
|
|
||||||
<div class="flex-y-center">
|
|
||||||
<DictLabel v-model="item.type" code="notice_type" size="small" />
|
|
||||||
<el-link
|
|
||||||
type="primary"
|
|
||||||
class="w200px cursor-pointer !ml-2 !flex-1"
|
|
||||||
@click="handleReadNotice(item.id)"
|
|
||||||
>
|
|
||||||
{{ item.title }}
|
|
||||||
</el-link>
|
|
||||||
<div class="text-xs text-gray-400">
|
|
||||||
{{ item.publishTime }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<div class="flex-center h150px w350px">
|
|
||||||
<el-empty :image-size="50" description="暂无待办" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<el-divider />
|
|
||||||
<div class="flex-x-between">
|
|
||||||
<el-link
|
|
||||||
v-if="tasks.length > 5"
|
|
||||||
type="primary"
|
|
||||||
:underline="false"
|
|
||||||
@click="handleViewMore"
|
|
||||||
>
|
|
||||||
<span class="text-xs">查看更多</span>
|
|
||||||
<el-icon class="text-xs">
|
|
||||||
<ArrowRight />
|
|
||||||
</el-icon>
|
|
||||||
</el-link>
|
|
||||||
<el-link
|
|
||||||
v-if="tasks.length > 0"
|
|
||||||
type="primary"
|
|
||||||
:underline="false"
|
|
||||||
@click="markAllAsRead"
|
|
||||||
>
|
|
||||||
<span class="text-xs">全部已读</span>
|
|
||||||
</el-link>
|
|
||||||
</div>
|
|
||||||
</el-tab-pane>
|
|
||||||
</el-tabs>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
@@ -167,45 +66,53 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import NoticeAPI, { NoticePageVO } from "@/api/system/notice";
|
import NoticeAPI, { NoticePageVO } from "@/api/system/notice";
|
||||||
import WebSocketManager from "@/utils/websocket";
|
|
||||||
import router from "@/router";
|
import router from "@/router";
|
||||||
|
|
||||||
const activeTab = ref("notice");
|
|
||||||
const notices = ref<NoticePageVO[]>([]);
|
const notices = ref<NoticePageVO[]>([]);
|
||||||
const messages = ref<any[]>([]);
|
|
||||||
const tasks = ref<any[]>([]);
|
|
||||||
const noticeDetailRef = ref();
|
const noticeDetailRef = ref();
|
||||||
|
|
||||||
// 获取未读消息列表并连接 WebSocket
|
import { useStomp } from "@/hooks/useStomp";
|
||||||
onMounted(() => {
|
const { subscribe, unsubscribe, isConnected } = useStomp();
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => isConnected.value,
|
||||||
|
(connected) => {
|
||||||
|
if (connected) {
|
||||||
|
subscribe("/user/queue/message", (message) => {
|
||||||
|
console.log("收到通知消息:", message);
|
||||||
|
const data = JSON.parse(message.body);
|
||||||
|
const id = data.id;
|
||||||
|
if (!notices.value.some((notice) => notice.id == id)) {
|
||||||
|
notices.value.unshift({
|
||||||
|
id,
|
||||||
|
title: data.title,
|
||||||
|
type: data.type,
|
||||||
|
publishTime: data.publishTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
ElNotification({
|
||||||
|
title: "您收到一条新的通知消息!",
|
||||||
|
message: data.title,
|
||||||
|
type: "success",
|
||||||
|
position: "bottom-right",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取我的通知公告
|
||||||
|
*/
|
||||||
|
function featchMyNotice() {
|
||||||
NoticeAPI.getMyNoticePage({ pageNum: 1, pageSize: 5, isRead: 0 }).then((data) => {
|
NoticeAPI.getMyNoticePage({ pageNum: 1, pageSize: 5, isRead: 0 }).then((data) => {
|
||||||
notices.value = data.list;
|
notices.value = data.list;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
WebSocketManager.subscribeToTopic("/user/queue/message", (message) => {
|
|
||||||
console.log("收到消息:", message);
|
|
||||||
const data = JSON.parse(message);
|
|
||||||
const id = data.id;
|
|
||||||
if (!notices.value.some((notice) => notice.id == id)) {
|
|
||||||
notices.value.unshift({
|
|
||||||
id,
|
|
||||||
title: data.title,
|
|
||||||
type: data.type,
|
|
||||||
publishTime: data.publishTime,
|
|
||||||
});
|
|
||||||
|
|
||||||
ElNotification({
|
|
||||||
title: "您收到一条新的通知消息!",
|
|
||||||
message: data.title,
|
|
||||||
type: "success",
|
|
||||||
position: "bottom-right",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 阅读通知公告
|
// 阅读通知公告
|
||||||
function handleReadNotice(id: string) {
|
function readNotice(id: string) {
|
||||||
noticeDetailRef.value.openNotice(id);
|
noticeDetailRef.value.openNotice(id);
|
||||||
const index = notices.value.findIndex((notice) => notice.id === id);
|
const index = notices.value.findIndex((notice) => notice.id === id);
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
@@ -214,7 +121,7 @@ function handleReadNotice(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 查看更多
|
// 查看更多
|
||||||
function handleViewMore() {
|
function viewMoreNotice() {
|
||||||
router.push({ path: "/myNotice" });
|
router.push({ path: "/myNotice" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,6 +131,14 @@ function markAllAsRead() {
|
|||||||
notices.value = [];
|
notices.value = [];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
featchMyNotice();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
unsubscribe("/user/queue/message");
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -234,4 +149,7 @@ function markAllAsRead() {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
:deep(.el-dropdown) {
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
<template>
|
|
||||||
<el-dropdown trigger="click">
|
|
||||||
<div class="flex-center h100% p13px">
|
|
||||||
<img :src="userStore.userInfo.avatar" class="rounded-full mr-10px w24px h24px" />
|
|
||||||
<span>{{ userStore.userInfo.username }}</span>
|
|
||||||
</div>
|
|
||||||
<template #dropdown>
|
|
||||||
<el-dropdown-menu>
|
|
||||||
<el-dropdown-item @click="handleOpenUserProfile">
|
|
||||||
{{ $t("navbar.profile") }}
|
|
||||||
</el-dropdown-item>
|
|
||||||
<el-dropdown-item divided @click="logout">
|
|
||||||
{{ $t("navbar.logout") }}
|
|
||||||
</el-dropdown-item>
|
|
||||||
</el-dropdown-menu>
|
|
||||||
</template>
|
|
||||||
</el-dropdown>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
defineOptions({
|
|
||||||
name: "UserProfile",
|
|
||||||
});
|
|
||||||
|
|
||||||
import { useTagsViewStore, useUserStore } from "@/store";
|
|
||||||
|
|
||||||
const tagsViewStore = useTagsViewStore();
|
|
||||||
const userStore = useUserStore();
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 打开个人中心页面
|
|
||||||
*/
|
|
||||||
function handleOpenUserProfile() {
|
|
||||||
router.push({ name: "Profile" });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 注销登出
|
|
||||||
*/
|
|
||||||
function logout() {
|
|
||||||
ElMessageBox.confirm("确定注销并退出系统吗?", "提示", {
|
|
||||||
confirmButtonText: "确定",
|
|
||||||
cancelButtonText: "取消",
|
|
||||||
type: "warning",
|
|
||||||
lockScroll: false,
|
|
||||||
}).then(() => {
|
|
||||||
userStore
|
|
||||||
.logout()
|
|
||||||
.then(() => {
|
|
||||||
tagsViewStore.delAllViews();
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
router.push(`/login?redirect=${route.fullPath}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped></style>
|
|
||||||
@@ -1,35 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-wrap justify-around w-full h-12">
|
<div class="layout-select">
|
||||||
<el-tooltip content="左侧模式" placement="bottom">
|
<el-tooltip
|
||||||
|
v-for="item in layoutOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:content="item.label"
|
||||||
|
placement="bottom"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="layout-item left"
|
role="button"
|
||||||
:class="{ 'is-active': modelValue === LayoutEnum.LEFT }"
|
tabindex="0"
|
||||||
@click="updateValue(LayoutEnum.LEFT)"
|
:class="['layout-item', item.className, { 'is-active': modelValue === item.value }]"
|
||||||
|
@click="handleLayoutChange(item.value)"
|
||||||
|
@keydown.enter.space="handleLayoutChange(item.value)"
|
||||||
>
|
>
|
||||||
<div />
|
<div class="layout-item-part" />
|
||||||
<div />
|
<div class="layout-item-part" />
|
||||||
</div>
|
|
||||||
</el-tooltip>
|
|
||||||
|
|
||||||
<el-tooltip content="顶部模式" placement="bottom">
|
|
||||||
<div
|
|
||||||
class="layout-item top"
|
|
||||||
:class="{ 'is-active': modelValue === LayoutEnum.TOP }"
|
|
||||||
@click="updateValue(LayoutEnum.TOP)"
|
|
||||||
>
|
|
||||||
<div />
|
|
||||||
<div />
|
|
||||||
</div>
|
|
||||||
</el-tooltip>
|
|
||||||
|
|
||||||
<el-tooltip content="混合模式" placement="bottom">
|
|
||||||
<div
|
|
||||||
class="layout-item mix"
|
|
||||||
:class="{ 'is-active': modelValue === LayoutEnum.MIX }"
|
|
||||||
@click="updateValue(LayoutEnum.MIX)"
|
|
||||||
>
|
|
||||||
<div />
|
|
||||||
<div />
|
|
||||||
</div>
|
</div>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,71 +23,118 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { LayoutEnum } from "@/enums/LayoutEnum";
|
import { LayoutEnum } from "@/enums/LayoutEnum";
|
||||||
|
|
||||||
const props = defineProps({
|
interface LayoutOption {
|
||||||
modelValue: String,
|
value: LayoutEnum;
|
||||||
|
label: string;
|
||||||
|
className: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const layoutOptions: LayoutOption[] = [
|
||||||
|
{ value: LayoutEnum.LEFT, label: "左侧模式", className: "left" },
|
||||||
|
{ value: LayoutEnum.TOP, label: "顶部模式", className: "top" },
|
||||||
|
{ value: LayoutEnum.MIX, label: "混合模式", className: "mix" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const modelValue = defineModel<LayoutEnum>("modelValue", {
|
||||||
|
required: true,
|
||||||
|
default: () => LayoutEnum.LEFT,
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(["update:modelValue"]);
|
function handleLayoutChange(layout: LayoutEnum) {
|
||||||
|
modelValue.value = layout;
|
||||||
function updateValue(layout: string) {
|
|
||||||
emit("update:modelValue", layout);
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped lang="scss">
|
||||||
.layout-selector {
|
.layout-select {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
gap: 10px;
|
||||||
justify-content: space-around;
|
justify-content: space-evenly;
|
||||||
width: 100%;
|
padding: 10px 0;
|
||||||
height: 50px;
|
--layout-primary: #1b2a47;
|
||||||
|
--layout-background: #f0f2f5;
|
||||||
|
--layout-shadow: 0 0 8px rgba(0, 0, 0, 0.1);
|
||||||
|
--layout-hover: #e3f1f9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout-item {
|
.layout-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 18%;
|
width: 18%;
|
||||||
height: 45px;
|
height: 50px;
|
||||||
overflow: hidden;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: #f0f2f5;
|
background: var(--layout-background);
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
|
box-shadow: var(--layout-shadow);
|
||||||
|
|
||||||
&.mix div:nth-child(1),
|
transition:
|
||||||
&.top div:nth-child(1) {
|
transform 0.2s ease,
|
||||||
width: 100%;
|
border-color 0.2s ease,
|
||||||
height: 30%;
|
box-shadow 0.2s ease;
|
||||||
background: #1b2a47;
|
|
||||||
box-shadow: 0 0 1px #888;
|
&:hover {
|
||||||
|
background-color: var(--layout-hover);
|
||||||
|
transform: scale(1.02); /* 稍微放大,避免过于夸张 */
|
||||||
}
|
}
|
||||||
|
|
||||||
&.mix div:nth-child(2) {
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&-part {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
background: var(--layout-primary);
|
||||||
left: 0;
|
border-radius: 4px; /* 保持和父容器一致的圆角 */
|
||||||
width: 30%;
|
box-shadow: var(--layout-shadow);
|
||||||
height: 70%;
|
transition: all 0.3s ease;
|
||||||
background: #1b2a47;
|
|
||||||
box-shadow: 0 0 1px #888;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.left div:nth-child(1) {
|
&.left {
|
||||||
width: 30%;
|
.layout-item-part {
|
||||||
height: 100%;
|
&:first-child {
|
||||||
background: #1b2a47;
|
width: 30%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 4px 0 0 4px; /* 左边部分圆角 */
|
||||||
|
}
|
||||||
|
&:last-child {
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 70%;
|
||||||
|
height: 30%;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 0 4px 4px 0; /* 右边部分圆角 */
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.left div:nth-child(2) {
|
&.top {
|
||||||
position: absolute;
|
.layout-item-part:first-child {
|
||||||
top: 0;
|
width: 100%;
|
||||||
right: 0;
|
height: 30%;
|
||||||
width: 70%;
|
border-radius: 4px 4px 0 0; /* 顶部部分圆角 */
|
||||||
height: 30%;
|
}
|
||||||
background: #fff;
|
}
|
||||||
box-shadow: 0 0 1px #888;
|
|
||||||
|
&.mix {
|
||||||
|
.layout-item-part {
|
||||||
|
&:first-child {
|
||||||
|
width: 100%;
|
||||||
|
height: 30%;
|
||||||
|
border-radius: 4px 4px 0 0; /* 顶部部分圆角 */
|
||||||
|
}
|
||||||
|
&:last-child {
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 30%;
|
||||||
|
height: 70%;
|
||||||
|
border-radius: 0 0 4px 4px; /* 底部部分圆角 */
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout-item.is-active {
|
.is-active {
|
||||||
|
background-color: var(--layout-hover);
|
||||||
border: 2px solid var(--el-color-primary);
|
border: 2px solid var(--el-color-primary);
|
||||||
|
transform: scale(1.05); /* 轻微放大 */
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<transition name="el-fade-in-linear" mode="out-in">
|
<transition enter-active-class="animate__animated animate__fadeInLeft">
|
||||||
<router-link :key="+collapse" class="wh-full flex-center" to="/">
|
<router-link :key="+collapse" class="wh-full flex-center" to="/">
|
||||||
<img :src="logo" class="w20px h20px" />
|
<img :src="logo" class="w20px h20px" />
|
||||||
<span v-if="!collapse" class="title">
|
<span v-if="!collapse" class="title">
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
>
|
>
|
||||||
<!-- 菜单项 -->
|
<!-- 菜单项 -->
|
||||||
<SidebarMenuItem
|
<SidebarMenuItem
|
||||||
v-for="route in menuList"
|
v-for="route in data"
|
||||||
:key="route.path"
|
:key="route.path"
|
||||||
:item="route"
|
:item="route"
|
||||||
:base-path="resolveFullPath(route.path)"
|
:base-path="resolveFullPath(route.path)"
|
||||||
@@ -39,6 +39,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import path from "path-browserify";
|
import path from "path-browserify";
|
||||||
import type { MenuInstance } from "element-plus";
|
import type { MenuInstance } from "element-plus";
|
||||||
|
import type { RouteRecordRaw } from "vue-router";
|
||||||
|
|
||||||
import { LayoutEnum } from "@/enums/LayoutEnum";
|
import { LayoutEnum } from "@/enums/LayoutEnum";
|
||||||
import { SidebarLightThemeEnum } from "@/enums/ThemeEnum";
|
import { SidebarLightThemeEnum } from "@/enums/ThemeEnum";
|
||||||
@@ -48,8 +49,8 @@ import { isExternal } from "@/utils/index";
|
|||||||
import variables from "@/styles/variables.module.scss";
|
import variables from "@/styles/variables.module.scss";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
menuList: {
|
data: {
|
||||||
type: Array<any>,
|
type: Array<RouteRecordRaw>,
|
||||||
required: true,
|
required: true,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -149,10 +149,10 @@ function resolvePath(routePath: string) {
|
|||||||
|
|
||||||
& > span {
|
& > span {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
visibility: hidden;
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 0;
|
height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
visibility: hidden;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,10 +178,10 @@ function resolvePath(routePath: string) {
|
|||||||
.el-sub-menu {
|
.el-sub-menu {
|
||||||
& > .el-sub-menu__title > span {
|
& > .el-sub-menu__title > span {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
visibility: hidden;
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 0;
|
height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
visibility: hidden;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- 根据 icon 类型决定使用的不同类型的图标组件 -->
|
<!-- 菜单图标 -->
|
||||||
<el-icon v-if="icon && icon.startsWith('el-icon')" class="sub-el-icon">
|
<template v-if="icon">
|
||||||
<component :is="icon.replace('el-icon-', '')" />
|
<el-icon v-if="isElIcon" class="el-icon">
|
||||||
</el-icon>
|
<component :is="iconComponent" />
|
||||||
<svg-icon v-else-if="icon" :icon-class="icon" />
|
</el-icon>
|
||||||
<svg-icon v-else icon-class="menu" />
|
<div v-else :class="`i-svg:${icon}`" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="i-svg:menu" />
|
||||||
|
</template>
|
||||||
<!-- 菜单标题 -->
|
<!-- 菜单标题 -->
|
||||||
<span v-if="title" class="ml-1">{{ translateRouteTitle(title) }}</span>
|
<span v-if="title" class="ml-1">{{ translateRouteTitle(title) }}</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -12,32 +16,38 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { translateRouteTitle } from "@/utils/i18n";
|
import { translateRouteTitle } from "@/utils/i18n";
|
||||||
|
|
||||||
defineProps({
|
const props = defineProps<{
|
||||||
icon: {
|
icon?: string;
|
||||||
type: String,
|
title?: string;
|
||||||
default: "",
|
}>();
|
||||||
},
|
|
||||||
title: {
|
const isElIcon = computed(() => props.icon?.startsWith("el-icon"));
|
||||||
type: String,
|
const iconComponent = computed(() => props.icon?.replace("el-icon-", ""));
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.sub-el-icon {
|
.el-icon {
|
||||||
width: 14px !important;
|
width: 14px !important;
|
||||||
margin-right: 0 !important;
|
margin-right: 0 !important;
|
||||||
color: currentcolor;
|
color: currentcolor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[class^="i-svg:"] {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: currentcolor !important;
|
||||||
|
}
|
||||||
|
|
||||||
.hideSidebar {
|
.hideSidebar {
|
||||||
.el-sub-menu,
|
.el-sub-menu,
|
||||||
.el-menu-item {
|
.el-menu-item {
|
||||||
.svg-icon,
|
.el-icon {
|
||||||
.sub-el-icon {
|
|
||||||
margin-left: 20px;
|
margin-left: 20px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[class^="i-svg:"] {
|
||||||
|
margin-left: 20px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
<el-icon v-if="route.meta.icon.startsWith('el-icon')" class="sub-el-icon">
|
<el-icon v-if="route.meta.icon.startsWith('el-icon')" class="sub-el-icon">
|
||||||
<component :is="route.meta.icon.replace('el-icon-', '')" />
|
<component :is="route.meta.icon.replace('el-icon-', '')" />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
<svg-icon v-else :icon-class="route.meta.icon" />
|
<div v-else :class="`i-svg:${route.meta.icon}`" />
|
||||||
</template>
|
</template>
|
||||||
<span v-if="route.path === '/'">首页</span>
|
<span v-if="route.path === '/'">首页</span>
|
||||||
<span v-else-if="route.meta && route.meta.title" class="ml-1">
|
<span v-else-if="route.meta && route.meta.title" class="ml-1">
|
||||||
@@ -40,18 +40,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
/**
|
|
||||||
* 导入模块:先外部库,再内部模块,最后导入样式和工具类
|
|
||||||
*/
|
|
||||||
import { LocationQueryRaw, RouteRecordRaw } from "vue-router";
|
import { LocationQueryRaw, RouteRecordRaw } from "vue-router";
|
||||||
import { usePermissionStore, useAppStore, useSettingsStore } from "@/store";
|
import { usePermissionStore, useAppStore, useSettingsStore } from "@/store";
|
||||||
import { translateRouteTitle } from "@/utils/i18n";
|
import { translateRouteTitle } from "@/utils/i18n";
|
||||||
import variables from "@/styles/variables.module.scss";
|
import variables from "@/styles/variables.module.scss";
|
||||||
import { SidebarLightThemeEnum } from "@/enums/ThemeEnum";
|
import { SidebarLightThemeEnum } from "@/enums/ThemeEnum";
|
||||||
|
|
||||||
/**
|
|
||||||
* 定义状态:先定义 reactive、ref 或 computed 状态
|
|
||||||
*/
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const appStore = useAppStore();
|
const appStore = useAppStore();
|
||||||
const permissionStore = usePermissionStore();
|
const permissionStore = usePermissionStore();
|
||||||
@@ -84,8 +78,8 @@ appStore.activeTopMenu(activeTopMenuPath);
|
|||||||
*/
|
*/
|
||||||
const handleMenuSelect = (routePath: string) => {
|
const handleMenuSelect = (routePath: string) => {
|
||||||
appStore.activeTopMenu(routePath); // 设置激活的顶部菜单
|
appStore.activeTopMenu(routePath); // 设置激活的顶部菜单
|
||||||
permissionStore.setMixLeftMenus(routePath); // 更新左侧菜单
|
permissionStore.setMixedLayoutLeftRoutes(routePath); // 更新左侧菜单
|
||||||
navigateToFirstLeftMenu(permissionStore.mixLeftMenus); // 跳转到左侧第一个菜单
|
navigateToFirstLeftMenu(permissionStore.mixedLayoutLeftRoutes); // 跳转到左侧第一个菜单
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div :class="{ 'has-logo': sidebarLogo }">
|
<div :class="{ 'has-logo': sidebarLogo }">
|
||||||
<!-- 混合布局顶部 -->
|
<!-- 混合布局 -->
|
||||||
<div v-if="isMixLayout" class="flex w-full">
|
<div v-if="layout == LayoutEnum.MIX" class="flex w-full">
|
||||||
<SidebarLogo v-if="sidebarLogo" :collapse="isSidebarCollapsed" />
|
<SidebarLogo v-if="sidebarLogo" :collapse="isSidebarCollapsed" />
|
||||||
<SidebarMixTopMenu class="flex-1" />
|
<SidebarMixTopMenu class="flex-1" />
|
||||||
<NavbarRight />
|
<NavbarRight />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 顶部布局顶部 || 左侧布局左侧 -->
|
<!-- 顶部布局 || 左侧布局 -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<SidebarLogo v-if="sidebarLogo" :collapse="isSidebarCollapsed" />
|
<SidebarLogo v-if="sidebarLogo" :collapse="isSidebarCollapsed" />
|
||||||
<el-scrollbar>
|
<el-scrollbar>
|
||||||
<SidebarMenu :menu-list="permissionStore.routes" base-path="" />
|
<SidebarMenu :data="permissionStore.routes" base-path="" />
|
||||||
</el-scrollbar>
|
</el-scrollbar>
|
||||||
|
|
||||||
<!-- 顶部布局导航 -->
|
<!-- 顶部导航 -->
|
||||||
<NavbarRight v-if="isTopLayout" />
|
<NavbarRight v-if="layout == LayoutEnum.TOP" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -33,8 +33,6 @@ const permissionStore = usePermissionStore();
|
|||||||
const sidebarLogo = computed(() => settingsStore.sidebarLogo);
|
const sidebarLogo = computed(() => settingsStore.sidebarLogo);
|
||||||
const layout = computed(() => settingsStore.layout);
|
const layout = computed(() => settingsStore.layout);
|
||||||
|
|
||||||
const isMixLayout = computed(() => layout.value === LayoutEnum.MIX);
|
|
||||||
const isTopLayout = computed(() => layout.value === LayoutEnum.TOP);
|
|
||||||
const isSidebarCollapsed = computed(() => !appStore.sidebar.opened);
|
const isSidebarCollapsed = computed(() => !appStore.sidebar.opened);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -28,27 +28,27 @@
|
|||||||
:style="{ left: left + 'px', top: top + 'px' }"
|
:style="{ left: left + 'px', top: top + 'px' }"
|
||||||
>
|
>
|
||||||
<li @click="refreshSelectedTag(selectedTag)">
|
<li @click="refreshSelectedTag(selectedTag)">
|
||||||
<svg-icon icon-class="refresh" />
|
<div class="i-svg:refresh" />
|
||||||
刷新
|
刷新
|
||||||
</li>
|
</li>
|
||||||
<li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">
|
<li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">
|
||||||
<svg-icon icon-class="close" />
|
<div class="i-svg:close" />
|
||||||
关闭
|
关闭
|
||||||
</li>
|
</li>
|
||||||
<li @click="closeOtherTags">
|
<li @click="closeOtherTags">
|
||||||
<svg-icon icon-class="close_other" />
|
<div class="i-svg:close_other" />
|
||||||
关闭其它
|
关闭其它
|
||||||
</li>
|
</li>
|
||||||
<li v-if="!isFirstView()" @click="closeLeftTags">
|
<li v-if="!isFirstView()" @click="closeLeftTags">
|
||||||
<svg-icon icon-class="close_left" />
|
<div class="i-svg:close_left" />
|
||||||
关闭左侧
|
关闭左侧
|
||||||
</li>
|
</li>
|
||||||
<li v-if="!isLastView()" @click="closeRightTags">
|
<li v-if="!isLastView()" @click="closeRightTags">
|
||||||
<svg-icon icon-class="close_right" />
|
<div class="i-svg:close_right" />
|
||||||
关闭右侧
|
关闭右侧
|
||||||
</li>
|
</li>
|
||||||
<li @click="closeAllTags(selectedTag)">
|
<li @click="closeAllTags(selectedTag)">
|
||||||
<svg-icon icon-class="close_all" />
|
<div class="i-svg:close_all" />
|
||||||
关闭所有
|
关闭所有
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -187,25 +187,17 @@ function isAffix(tag: TagView) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isFirstView() {
|
function isFirstView() {
|
||||||
try {
|
return (
|
||||||
return (
|
selectedTag.value.path === "/dashboard" ||
|
||||||
selectedTag.value.path === "/dashboard" ||
|
selectedTag.value.fullPath === tagsViewStore.visitedViews[1]?.fullPath
|
||||||
selectedTag.value.fullPath === tagsViewStore.visitedViews[1].fullPath
|
);
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLastView() {
|
function isLastView() {
|
||||||
try {
|
return (
|
||||||
return (
|
selectedTag.value.fullPath ===
|
||||||
selectedTag.value.fullPath ===
|
tagsViewStore.visitedViews[tagsViewStore.visitedViews.length - 1]?.fullPath
|
||||||
tagsViewStore.visitedViews[tagsViewStore.visitedViews.length - 1].fullPath
|
);
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshSelectedTag(view: TagView) {
|
function refreshSelectedTag(view: TagView) {
|
||||||
|
|||||||
@@ -14,33 +14,33 @@
|
|||||||
<div v-if="layout === LayoutEnum.MIX" class="mix-container">
|
<div v-if="layout === LayoutEnum.MIX" class="mix-container">
|
||||||
<div class="mix-container-sidebar">
|
<div class="mix-container-sidebar">
|
||||||
<el-scrollbar>
|
<el-scrollbar>
|
||||||
<SidebarMenu :menu-list="mixLeftMenus" :base-path="activeTopMenuPath" />
|
<SidebarMenu :data="mixedLayoutLeftRoutes" :base-path="activeTopMenuPath" />
|
||||||
</el-scrollbar>
|
</el-scrollbar>
|
||||||
<div class="sidebar-toggle">
|
<div class="sidebar-toggle">
|
||||||
<hamburger :is-active="appStore.sidebar.opened" @toggle-click="toggleSidebar" />
|
<hamburger :is-active="appStore.sidebar.opened" @toggle-click="toggleSidebar" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div :class="{ hasTagsView: showTagsView }" class="main-container">
|
<div :class="{ hasTagsView: isShowTagsView }" class="main-container">
|
||||||
<TagsView v-if="showTagsView" />
|
<TagsView v-if="isShowTagsView" />
|
||||||
<AppMain />
|
<AppMain />
|
||||||
<Settings v-if="defaultSettings.showSettings" />
|
<Settings v-if="defaultSettings.showSettings" />
|
||||||
<!-- 返回顶部 -->
|
<!-- 返回顶部 -->
|
||||||
<el-backtop target=".app-main">
|
<el-backtop target=".app-main">
|
||||||
<svg-icon icon-class="backtop" size="24px" />
|
<div class="i-svg:backtop w-6 h-6" />
|
||||||
</el-backtop>
|
</el-backtop>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 左侧和顶部布局 -->
|
<!-- 左侧和顶部布局 -->
|
||||||
<div v-else :class="{ hasTagsView: showTagsView }" class="main-container">
|
<div v-else :class="{ hasTagsView: isShowTagsView }" class="main-container">
|
||||||
<NavBar v-if="layout === LayoutEnum.LEFT" />
|
<NavBar v-if="layout === LayoutEnum.LEFT" />
|
||||||
<TagsView v-if="showTagsView" />
|
<TagsView v-if="isShowTagsView" />
|
||||||
<AppMain />
|
<AppMain />
|
||||||
<Settings v-if="defaultSettings.showSettings" />
|
<Settings v-if="defaultSettings.showSettings" />
|
||||||
<!-- 返回顶部 -->
|
<!-- 返回顶部 -->
|
||||||
<el-backtop target=".app-main">
|
<el-backtop target=".app-main">
|
||||||
<svg-icon icon-class="backtop" size="24px" />
|
<div class="i-svg:backtop w-6 h-6" />
|
||||||
</el-backtop>
|
</el-backtop>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,15 +62,15 @@ const width = useWindowSize().width;
|
|||||||
const WIDTH_DESKTOP = 992; // 响应式布局容器固定宽度 大屏(>=1200px) 中屏(>=992px) 小屏(>=768px)
|
const WIDTH_DESKTOP = 992; // 响应式布局容器固定宽度 大屏(>=1200px) 中屏(>=992px) 小屏(>=768px)
|
||||||
const isMobile = computed(() => appStore.device === DeviceEnum.MOBILE);
|
const isMobile = computed(() => appStore.device === DeviceEnum.MOBILE);
|
||||||
const isOpenSidebar = computed(() => appStore.sidebar.opened);
|
const isOpenSidebar = computed(() => appStore.sidebar.opened);
|
||||||
const showTagsView = computed(() => settingsStore.tagsView); // 是否显示tagsView
|
const isShowTagsView = computed(() => settingsStore.tagsView); // 是否显示tagsView
|
||||||
const layout = computed(() => settingsStore.layout); // 布局模式 left top mix
|
const layout = computed(() => settingsStore.layout); // 布局模式 left top mix
|
||||||
const activeTopMenuPath = computed(() => appStore.activeTopMenuPath); // 顶部菜单激活path
|
const activeTopMenuPath = computed(() => appStore.activeTopMenuPath); // 顶部菜单激活path
|
||||||
const mixLeftMenus = computed(() => permissionStore.mixLeftMenus); // 混合布局左侧菜单
|
const mixedLayoutLeftRoutes = computed(() => permissionStore.mixedLayoutLeftRoutes); // 混合布局左侧菜单
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => activeTopMenuPath.value,
|
() => activeTopMenuPath.value,
|
||||||
(newVal: string) => {
|
(newVal: string) => {
|
||||||
permissionStore.setMixLeftMenus(newVal);
|
permissionStore.setMixedLayoutLeftRoutes(newVal);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
deep: true,
|
deep: true,
|
||||||
@@ -245,8 +245,10 @@ watch(route, () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hideSidebar {
|
.hideSidebar {
|
||||||
.main-container {
|
&.layout-left {
|
||||||
margin-left: $sidebar-width-collapsed;
|
.main-container {
|
||||||
|
margin-left: $sidebar-width-collapsed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.layout-top {
|
&.layout-top {
|
||||||
@@ -280,8 +282,8 @@ watch(route, () => {
|
|||||||
&.mobile {
|
&.mobile {
|
||||||
.sidebar-container {
|
.sidebar-container {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
transform: translate3d(-$sidebar-width, 0, 0);
|
||||||
transition-duration: 0.3s;
|
transition-duration: 0.3s;
|
||||||
transform: translate3d(-210px, 0, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-container {
|
.main-container {
|
||||||
@@ -291,13 +293,10 @@ watch(route, () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mobile {
|
.mobile {
|
||||||
.main-container {
|
.layout-mix,
|
||||||
|
.layout-top,
|
||||||
|
.layout-left {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.layout-top {
|
|
||||||
// 顶部模式全局变量修改
|
|
||||||
--el-menu-item-height: $navbar-height;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { createApp } from "vue";
|
|||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
import setupPlugins from "@/plugins";
|
import setupPlugins from "@/plugins";
|
||||||
|
|
||||||
// 本地SVG图标
|
|
||||||
import "virtual:svg-icons-register";
|
|
||||||
// 暗黑主题样式
|
// 暗黑主题样式
|
||||||
import "element-plus/theme-chalk/dark/css-vars.css";
|
import "element-plus/theme-chalk/dark/css-vars.css";
|
||||||
// 暗黑模式自定义变量
|
// 暗黑模式自定义变量
|
||||||
@@ -11,6 +9,12 @@ import "@/styles/dark/css-vars.css";
|
|||||||
import "@/styles/index.scss";
|
import "@/styles/index.scss";
|
||||||
import "uno.css";
|
import "uno.css";
|
||||||
|
|
||||||
|
// 全局引入 animate.css
|
||||||
|
import "animate.css";
|
||||||
|
|
||||||
|
// 自动为某些默认事件(如 touchstart、wheel 等)添加 { passive: true },提升滚动性能并消除控制台的非被动事件监听警告
|
||||||
|
import "default-passive-events";
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
// 注册插件
|
// 注册插件
|
||||||
app.use(setupPlugins);
|
app.use(setupPlugins);
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { setupRouter } from "@/router";
|
|||||||
import { setupStore } from "@/store";
|
import { setupStore } from "@/store";
|
||||||
import { setupElIcons } from "./icons";
|
import { setupElIcons } from "./icons";
|
||||||
import { setupPermission } from "./permission";
|
import { setupPermission } from "./permission";
|
||||||
import webSocketManager from "@/utils/websocket";
|
|
||||||
import { InstallCodeMirror } from "codemirror-editor-vue3";
|
import { InstallCodeMirror } from "codemirror-editor-vue3";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -23,8 +22,6 @@ export default {
|
|||||||
setupElIcons(app);
|
setupElIcons(app);
|
||||||
// 路由守卫
|
// 路由守卫
|
||||||
setupPermission();
|
setupPermission();
|
||||||
// 初始化 WebSocket
|
|
||||||
webSocketManager.setupWebSocket();
|
|
||||||
// 注册 CodeMirror
|
// 注册 CodeMirror
|
||||||
app.use(InstallCodeMirror);
|
app.use(InstallCodeMirror);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { NavigationGuardNext, RouteLocationNormalized, RouteRecordRaw } from "vue-router";
|
import type { NavigationGuardNext, RouteLocationNormalized, RouteRecordRaw } from "vue-router";
|
||||||
import NProgress from "@/utils/nprogress";
|
import NProgress from "@/utils/nprogress";
|
||||||
import { getToken } from "@/utils/auth";
|
import { getAccessToken } from "@/utils/auth";
|
||||||
import router from "@/router";
|
import router from "@/router";
|
||||||
import { usePermissionStore, useUserStore } from "@/store";
|
import { usePermissionStore, useUserStore } from "@/store";
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ export function setupPermission() {
|
|||||||
router.beforeEach(async (to, from, next) => {
|
router.beforeEach(async (to, from, next) => {
|
||||||
NProgress.start();
|
NProgress.start();
|
||||||
|
|
||||||
const isLogin = !!getToken(); // 判断是否登录
|
const isLogin = !!getAccessToken(); // 判断是否登录
|
||||||
if (isLogin) {
|
if (isLogin) {
|
||||||
if (to.path === "/login") {
|
if (to.path === "/login") {
|
||||||
// 已登录,访问登录页,跳转到首页
|
// 已登录,访问登录页,跳转到首页
|
||||||
|
|||||||
@@ -8,22 +8,24 @@ const modules = import.meta.glob("../../views/**/**.vue");
|
|||||||
const Layout = () => import("@/layout/index.vue");
|
const Layout = () => import("@/layout/index.vue");
|
||||||
|
|
||||||
export const usePermissionStore = defineStore("permission", () => {
|
export const usePermissionStore = defineStore("permission", () => {
|
||||||
// 所有路由,包括静态和动态路由
|
// 储所有路由,包括静态路由和动态路由
|
||||||
const routes = ref<RouteRecordRaw[]>([]);
|
const routes = ref<RouteRecordRaw[]>([]);
|
||||||
// 混合模式左侧菜单
|
// 混合模式左侧菜单路由
|
||||||
const mixLeftMenus = ref<RouteRecordRaw[]>([]);
|
const mixedLayoutLeftRoutes = ref<RouteRecordRaw[]>([]);
|
||||||
// 路由是否已加载
|
// 路由是否加载完成
|
||||||
const isRoutesLoaded = ref(false);
|
const isRoutesLoaded = ref(false);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成动态路由
|
* 获取后台动态路由数据,解析并注册到全局路由
|
||||||
|
*
|
||||||
|
* @returns Promise<RouteRecordRaw[]> 解析后的动态路由列表
|
||||||
*/
|
*/
|
||||||
function generateRoutes() {
|
function generateRoutes() {
|
||||||
return new Promise<RouteRecordRaw[]>((resolve, reject) => {
|
return new Promise<RouteRecordRaw[]>((resolve, reject) => {
|
||||||
MenuAPI.getRoutes()
|
MenuAPI.getRoutes()
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
const dynamicRoutes = transformRoutes(data);
|
const dynamicRoutes = parseDynamicRoutes(data);
|
||||||
routes.value = constantRoutes.concat(dynamicRoutes);
|
routes.value = [...constantRoutes, ...dynamicRoutes];
|
||||||
isRoutesLoaded.value = true;
|
isRoutesLoaded.value = true;
|
||||||
resolve(dynamicRoutes);
|
resolve(dynamicRoutes);
|
||||||
})
|
})
|
||||||
@@ -34,14 +36,14 @@ export const usePermissionStore = defineStore("permission", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 混合模式菜单下根据顶部菜单路径设置左侧菜单
|
* 根据父菜单路径设置混合模式左侧菜单
|
||||||
*
|
*
|
||||||
* @param topMenuPath - 顶部菜单路径
|
* @param parentPath 父菜单的路径,用于查找对应的菜单项
|
||||||
*/
|
*/
|
||||||
const setMixLeftMenus = (topMenuPath: string) => {
|
const setMixedLayoutLeftRoutes = (parentPath: string) => {
|
||||||
const matchedItem = routes.value.find((item) => item.path === topMenuPath);
|
const matchedItem = routes.value.find((item) => item.path === parentPath);
|
||||||
if (matchedItem && matchedItem.children) {
|
if (matchedItem && matchedItem.children) {
|
||||||
mixLeftMenus.value = matchedItem.children;
|
mixedLayoutLeftRoutes.value = matchedItem.children;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,59 +51,63 @@ export const usePermissionStore = defineStore("permission", () => {
|
|||||||
* 重置路由
|
* 重置路由
|
||||||
*/
|
*/
|
||||||
const resetRouter = () => {
|
const resetRouter = () => {
|
||||||
// 删除动态路由,保留静态路由
|
// 从 router 实例中移除动态路由
|
||||||
routes.value.forEach((route) => {
|
routes.value.forEach((route) => {
|
||||||
if (route.name && !constantRoutes.find((r) => r.name === route.name)) {
|
if (route.name && !constantRoutes.find((r) => r.name === route.name)) {
|
||||||
// 从 router 实例中移除动态路由
|
|
||||||
router.removeRoute(route.name);
|
router.removeRoute(route.name);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 清空本地存储的路由和菜单数据
|
||||||
routes.value = [];
|
routes.value = [];
|
||||||
mixLeftMenus.value = [];
|
mixedLayoutLeftRoutes.value = [];
|
||||||
isRoutesLoaded.value = false;
|
isRoutesLoaded.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
routes,
|
routes,
|
||||||
generateRoutes,
|
mixedLayoutLeftRoutes,
|
||||||
mixLeftMenus,
|
|
||||||
setMixLeftMenus,
|
|
||||||
isRoutesLoaded,
|
isRoutesLoaded,
|
||||||
|
generateRoutes,
|
||||||
|
setMixedLayoutLeftRoutes,
|
||||||
resetRouter,
|
resetRouter,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 转换路由数据为组件
|
* 解析后端返回的路由数据并转换为 Vue Router 兼容的路由配置
|
||||||
|
*
|
||||||
|
* 1. 遍历 `rawRoutes` 并转换为 `RouteRecordRaw` 格式。
|
||||||
|
* 2. 若 `component` 为 `"Layout"`,则替换为 `Layout` 组件。
|
||||||
|
* 3. 若 `component` 为字符串路径,则动态加载对应的 Vue 组件,找不到则默认 `404.vue`。
|
||||||
|
* 4. 递归解析 `children`,确保子路由也被正确转换。
|
||||||
|
*
|
||||||
|
* @param rawRoutes 后端返回的原始路由数据
|
||||||
|
* @returns 解析后的路由配置数组
|
||||||
*/
|
*/
|
||||||
const transformRoutes = (routes: RouteVO[]) => {
|
const parseDynamicRoutes = (rawRoutes: RouteVO[]): RouteRecordRaw[] => {
|
||||||
const asyncRoutes: RouteRecordRaw[] = [];
|
const parsedRoutes: RouteRecordRaw[] = [];
|
||||||
routes.forEach((route) => {
|
|
||||||
const tmpRoute = { ...route } as RouteRecordRaw;
|
rawRoutes.forEach((route) => {
|
||||||
// 顶级目录,替换为 Layout 组件
|
const normalizedRoute = { ...route } as RouteRecordRaw;
|
||||||
if (tmpRoute.component?.toString() == "Layout") {
|
|
||||||
tmpRoute.component = Layout;
|
// 处理组件路径
|
||||||
} else {
|
normalizedRoute.component =
|
||||||
// 其他菜单,根据组件路径动态加载组件
|
normalizedRoute.component?.toString() === "Layout"
|
||||||
const component = modules[`../../views/${tmpRoute.component}.vue`];
|
? Layout
|
||||||
if (component) {
|
: modules[`../../views/${normalizedRoute.component}.vue`] ||
|
||||||
tmpRoute.component = component;
|
modules["../../views/error-page/404.vue"];
|
||||||
} else {
|
|
||||||
tmpRoute.component = modules["../../views/error-page/404.vue"];
|
// 递归解析子路由
|
||||||
}
|
if (normalizedRoute.children) {
|
||||||
|
normalizedRoute.children = parseDynamicRoutes(route.children);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tmpRoute.children) {
|
parsedRoutes.push(normalizedRoute);
|
||||||
tmpRoute.children = transformRoutes(route.children);
|
|
||||||
}
|
|
||||||
|
|
||||||
asyncRoutes.push(tmpRoute);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return asyncRoutes;
|
return parsedRoutes;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 在组件外使用 Pinia store 实例 @see https://pinia.vuejs.org/core-concepts/outside-component-usage.html
|
* 在组件外使用 Pinia store 实例 @see https://pinia.vuejs.org/core-concepts/outside-component-usage.html
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useDictStoreHook } from "@/store/modules/dict";
|
|||||||
import AuthAPI, { type LoginFormData } from "@/api/auth";
|
import AuthAPI, { type LoginFormData } from "@/api/auth";
|
||||||
import UserAPI, { type UserInfo } from "@/api/system/user";
|
import UserAPI, { type UserInfo } from "@/api/system/user";
|
||||||
|
|
||||||
import { setToken, setRefreshToken, getRefreshToken, clearToken } from "@/utils/auth";
|
import { setAccessToken, setRefreshToken, getRefreshToken, clearToken } from "@/utils/auth";
|
||||||
|
|
||||||
export const useUserStore = defineStore("user", () => {
|
export const useUserStore = defineStore("user", () => {
|
||||||
const userInfo = useStorage<UserInfo>("userInfo", {} as UserInfo);
|
const userInfo = useStorage<UserInfo>("userInfo", {} as UserInfo);
|
||||||
@@ -20,8 +20,8 @@ export const useUserStore = defineStore("user", () => {
|
|||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
AuthAPI.login(LoginFormData)
|
AuthAPI.login(LoginFormData)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
const { tokenType, accessToken, refreshToken } = data;
|
const { accessToken, refreshToken } = data;
|
||||||
setToken(tokenType + " " + accessToken); // Bearer eyJhbGciOiJIUzI1NiJ9.xxx.xxx
|
setAccessToken(accessToken); // eyJhbGciOiJIUzI1NiJ9.xxx.xxx
|
||||||
setRefreshToken(refreshToken);
|
setRefreshToken(refreshToken);
|
||||||
resolve();
|
resolve();
|
||||||
})
|
})
|
||||||
@@ -77,8 +77,8 @@ export const useUserStore = defineStore("user", () => {
|
|||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
AuthAPI.refreshToken(refreshToken)
|
AuthAPI.refreshToken(refreshToken)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
const { tokenType, accessToken, refreshToken } = data;
|
const { accessToken, refreshToken } = data;
|
||||||
setToken(tokenType + " " + accessToken);
|
setAccessToken(accessToken);
|
||||||
setRefreshToken(refreshToken);
|
setRefreshToken(refreshToken);
|
||||||
resolve();
|
resolve();
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB",
|
font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
|
||||||
"Microsoft YaHei", "微软雅黑", Arial, sans-serif;
|
"微软雅黑", Arial, sans-serif;
|
||||||
line-height: inherit;
|
line-height: inherit;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
|||||||
4
src/types/components.d.ts
vendored
4
src/types/components.d.ts
vendored
@@ -13,6 +13,7 @@ declare module "vue" {
|
|||||||
CURD: (typeof import("./../components/CURD/index.vue"))["default"];
|
CURD: (typeof import("./../components/CURD/index.vue"))["default"];
|
||||||
Dict: (typeof import("./../components/Dict/index.vue"))["default"];
|
Dict: (typeof import("./../components/Dict/index.vue"))["default"];
|
||||||
DictLabel: (typeof import("./../components/Dict/DictLabel.vue"))["default"];
|
DictLabel: (typeof import("./../components/Dict/DictLabel.vue"))["default"];
|
||||||
|
ECharts: (typeof import("./../components/ECharts/index.vue"))["default"];
|
||||||
ElBacktop: (typeof import("element-plus/es"))["ElBacktop"];
|
ElBacktop: (typeof import("element-plus/es"))["ElBacktop"];
|
||||||
ElBreadcrumb: (typeof import("element-plus/es"))["ElBreadcrumb"];
|
ElBreadcrumb: (typeof import("element-plus/es"))["ElBreadcrumb"];
|
||||||
ElBreadcrumbItem: (typeof import("element-plus/es"))["ElBreadcrumbItem"];
|
ElBreadcrumbItem: (typeof import("element-plus/es"))["ElBreadcrumbItem"];
|
||||||
@@ -84,12 +85,11 @@ declare module "vue" {
|
|||||||
SidebarMenuItem: (typeof import("./../layout/components/Sidebar/components/SidebarMenuItem.vue"))["default"];
|
SidebarMenuItem: (typeof import("./../layout/components/Sidebar/components/SidebarMenuItem.vue"))["default"];
|
||||||
SidebarMenuItemTitle: (typeof import("./../layout/components/Sidebar/components/SidebarMenuItemTitle.vue"))["default"];
|
SidebarMenuItemTitle: (typeof import("./../layout/components/Sidebar/components/SidebarMenuItemTitle.vue"))["default"];
|
||||||
SidebarMixTopMenu: (typeof import("./../layout/components/Sidebar/components/SidebarMixTopMenu.vue"))["default"];
|
SidebarMixTopMenu: (typeof import("./../layout/components/Sidebar/components/SidebarMixTopMenu.vue"))["default"];
|
||||||
|
SingleImageUpload: (typeof import("./../components/Upload/SingleImageUpload.vue"))["default"];
|
||||||
SizeSelect: (typeof import("./../components/SizeSelect/index.vue"))["default"];
|
SizeSelect: (typeof import("./../components/SizeSelect/index.vue"))["default"];
|
||||||
SvgIcon: (typeof import("./../components/SvgIcon/index.vue"))["default"];
|
|
||||||
TableSelect: (typeof import("./../components/TableSelect/index.vue"))["default"];
|
TableSelect: (typeof import("./../components/TableSelect/index.vue"))["default"];
|
||||||
TagsView: (typeof import("./../layout/components/TagsView/index.vue"))["default"];
|
TagsView: (typeof import("./../layout/components/TagsView/index.vue"))["default"];
|
||||||
ThemeColorPicker: (typeof import("./../layout/components/Settings/components/ThemeColorPicker.vue"))["default"];
|
ThemeColorPicker: (typeof import("./../layout/components/Settings/components/ThemeColorPicker.vue"))["default"];
|
||||||
SingleImageUpload: (typeof import("./../components/Upload/SingleImageUpload.vue"))["default"];
|
|
||||||
WangEditor: (typeof import("./../components/WangEditor/index.vue"))["default"];
|
WangEditor: (typeof import("./../components/WangEditor/index.vue"))["default"];
|
||||||
}
|
}
|
||||||
export interface ComponentCustomProperties {
|
export interface ComponentCustomProperties {
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ const ACCESS_TOKEN_KEY = "access_token";
|
|||||||
// 刷新 token 缓存的 key
|
// 刷新 token 缓存的 key
|
||||||
const REFRESH_TOKEN_KEY = "refresh_token";
|
const REFRESH_TOKEN_KEY = "refresh_token";
|
||||||
|
|
||||||
function getToken(): string {
|
function getAccessToken(): string {
|
||||||
return localStorage.getItem(ACCESS_TOKEN_KEY) || "";
|
return localStorage.getItem(ACCESS_TOKEN_KEY) || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function setToken(token: string) {
|
function setAccessToken(token: string) {
|
||||||
localStorage.setItem(ACCESS_TOKEN_KEY, token);
|
localStorage.setItem(ACCESS_TOKEN_KEY, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,4 +24,4 @@ function clearToken() {
|
|||||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { getToken, setToken, clearToken, getRefreshToken, setRefreshToken };
|
export { getAccessToken, setAccessToken, clearToken, getRefreshToken, setRefreshToken };
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import axios, { type InternalAxiosRequestConfig, type AxiosResponse } from "axio
|
|||||||
import qs from "qs";
|
import qs from "qs";
|
||||||
import { useUserStoreHook } from "@/store/modules/user";
|
import { useUserStoreHook } from "@/store/modules/user";
|
||||||
import { ResultEnum } from "@/enums/ResultEnum";
|
import { ResultEnum } from "@/enums/ResultEnum";
|
||||||
import { getToken } from "@/utils/auth";
|
import { getAccessToken } from "@/utils/auth";
|
||||||
import router from "@/router";
|
import router from "@/router";
|
||||||
|
|
||||||
// 创建 axios 实例
|
// 创建 axios 实例
|
||||||
@@ -16,10 +16,10 @@ const service = axios.create({
|
|||||||
// 请求拦截器
|
// 请求拦截器
|
||||||
service.interceptors.request.use(
|
service.interceptors.request.use(
|
||||||
(config: InternalAxiosRequestConfig) => {
|
(config: InternalAxiosRequestConfig) => {
|
||||||
const accessToken = getToken();
|
const accessToken = getAccessToken();
|
||||||
// 如果 Authorization 设置为 no-auth,则不携带 Token,用于登录、刷新 Token 等接口
|
// 如果 Authorization 设置为 no-auth,则不携带 Token,用于登录、刷新 Token 等接口
|
||||||
if (config.headers.Authorization !== "no-auth" && accessToken) {
|
if (config.headers.Authorization !== "no-auth" && accessToken) {
|
||||||
config.headers.Authorization = accessToken;
|
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||||
} else {
|
} else {
|
||||||
delete config.headers.Authorization;
|
delete config.headers.Authorization;
|
||||||
}
|
}
|
||||||
@@ -44,7 +44,8 @@ service.interceptors.response.use(
|
|||||||
ElMessage.error(msg || "系统出错");
|
ElMessage.error(msg || "系统出错");
|
||||||
return Promise.reject(new Error(msg || "Error"));
|
return Promise.reject(new Error(msg || "Error"));
|
||||||
},
|
},
|
||||||
async (error: any) => {
|
async (error) => {
|
||||||
|
console.error("request error", error); // for debug
|
||||||
// 非 2xx 状态码处理 401、403、500 等
|
// 非 2xx 状态码处理 401、403、500 等
|
||||||
const { config, response } = error;
|
const { config, response } = error;
|
||||||
if (response) {
|
if (response) {
|
||||||
@@ -64,20 +65,21 @@ service.interceptors.response.use(
|
|||||||
|
|
||||||
export default service;
|
export default service;
|
||||||
|
|
||||||
// 刷新 Token 的锁
|
// 是否正在刷新标识,避免重复刷新
|
||||||
let isRefreshing = false;
|
let isRefreshing = false;
|
||||||
// 因 Token 过期导致失败的请求队列
|
// 因 Token 过期导致的请求等待队列
|
||||||
let requestsQueue: Array<() => void> = [];
|
const waitingQueue: Array<() => void> = [];
|
||||||
|
|
||||||
// 刷新 Token 处理
|
// 刷新 Token 处理
|
||||||
async function handleTokenRefresh(config: InternalAxiosRequestConfig) {
|
async function handleTokenRefresh(config: InternalAxiosRequestConfig) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const requestCallback = () => {
|
// 封装需要重试的请求
|
||||||
config.headers.Authorization = getToken();
|
const retryRequest = () => {
|
||||||
|
config.headers.Authorization = getAccessToken();
|
||||||
resolve(service(config));
|
resolve(service(config));
|
||||||
};
|
};
|
||||||
|
|
||||||
requestsQueue.push(requestCallback);
|
waitingQueue.push(retryRequest);
|
||||||
|
|
||||||
if (!isRefreshing) {
|
if (!isRefreshing) {
|
||||||
isRefreshing = true;
|
isRefreshing = true;
|
||||||
@@ -86,13 +88,13 @@ async function handleTokenRefresh(config: InternalAxiosRequestConfig) {
|
|||||||
useUserStoreHook()
|
useUserStoreHook()
|
||||||
.refreshToken()
|
.refreshToken()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// Token 刷新成功,执行请求队列
|
// 依次重试队列中所有请求, 重试后清空队列
|
||||||
requestsQueue.forEach((callback) => callback());
|
waitingQueue.forEach((callback) => callback());
|
||||||
requestsQueue = [];
|
waitingQueue.length = 0;
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error: any) => {
|
||||||
console.log("handleTokenRefresh error", error);
|
console.log("handleTokenRefresh error", error);
|
||||||
// Token 刷新失败,清除用户数据并跳转到登录
|
// 刷新 Token 失败,跳转登录页
|
||||||
ElNotification({
|
ElNotification({
|
||||||
title: "提示",
|
title: "提示",
|
||||||
message: "您的会话已过期,请重新登录",
|
message: "您的会话已过期,请重新登录",
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
import { Client } from "@stomp/stompjs";
|
|
||||||
import { getToken } from "@/utils/auth";
|
|
||||||
|
|
||||||
class WebSocketManager {
|
|
||||||
private client: Client | null = null;
|
|
||||||
private messageHandlers: Map<string, ((message: string) => void)[]> = new Map();
|
|
||||||
private reconnectAttempts = 0;
|
|
||||||
private maxReconnectAttempts = 3; // 自定义最大重试次数
|
|
||||||
private reconnectDelay = 5000; // 重试延迟(单位:毫秒)
|
|
||||||
|
|
||||||
// 初始化 WebSocket 客户端
|
|
||||||
setupWebSocket() {
|
|
||||||
const endpoint = import.meta.env.VITE_APP_WS_ENDPOINT;
|
|
||||||
|
|
||||||
// 如果没有配置 WebSocket 端点或显式关闭,直接返回
|
|
||||||
if (!endpoint) {
|
|
||||||
console.log("WebSocket 已被禁用,如需打开请在配置文件中配置 VITE_APP_WS_ENDPOINT");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.client && this.client.connected) {
|
|
||||||
console.log("客户端已存在并且连接正常");
|
|
||||||
return this.client;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.client = new Client({
|
|
||||||
brokerURL: endpoint,
|
|
||||||
connectHeaders: {
|
|
||||||
Authorization: getToken(),
|
|
||||||
},
|
|
||||||
heartbeatIncoming: 30000,
|
|
||||||
heartbeatOutgoing: 30000,
|
|
||||||
reconnectDelay: 0, // 设置为 0 禁用重连
|
|
||||||
onConnect: () => {
|
|
||||||
console.log(`连接到 WebSocket 服务器: ${endpoint}`);
|
|
||||||
this.reconnectAttempts = 0; // 重置重连计数
|
|
||||||
this.messageHandlers.forEach((handlers, topic) => {
|
|
||||||
handlers.forEach((handler) => {
|
|
||||||
this.subscribeToTopic(topic, handler);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onStompError: (frame) => {
|
|
||||||
console.error(`连接错误: ${frame.headers["message"]}`);
|
|
||||||
console.error(`错误详情: ${frame.body}`);
|
|
||||||
},
|
|
||||||
onDisconnect: () => {
|
|
||||||
console.log(`WebSocket 连接已断开: ${endpoint}`);
|
|
||||||
this.reconnectAttempts++;
|
|
||||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
||||||
console.log(`正在尝试重连... 尝试次数: ${this.reconnectAttempts}`);
|
|
||||||
} else {
|
|
||||||
console.log("重连次数已达上限,停止重连");
|
|
||||||
this.client?.deactivate();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.client.activate();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 订阅主题
|
|
||||||
public subscribeToTopic(topic: string, onMessage: (message: string) => void) {
|
|
||||||
console.log(`正在订阅主题: ${topic}`);
|
|
||||||
if (!this.client || !this.client.connected) {
|
|
||||||
this.setupWebSocket();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.messageHandlers.has(topic)) {
|
|
||||||
this.messageHandlers.get(topic)?.push(onMessage);
|
|
||||||
} else {
|
|
||||||
this.messageHandlers.set(topic, [onMessage]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.client?.connected) {
|
|
||||||
this.client.subscribe(topic, (message) => {
|
|
||||||
const handlers = this.messageHandlers.get(topic);
|
|
||||||
handlers?.forEach((handler) => handler(message.body));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 断开 WebSocket 连接
|
|
||||||
public disconnect() {
|
|
||||||
if (this.client) {
|
|
||||||
console.log("断开 WebSocket 连接");
|
|
||||||
this.client.deactivate();
|
|
||||||
this.client = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default new WebSocketManager();
|
|
||||||
@@ -354,7 +354,7 @@
|
|||||||
@node-click="handleFileTreeNodeClick"
|
@node-click="handleFileTreeNodeClick"
|
||||||
>
|
>
|
||||||
<template #default="{ data }">
|
<template #default="{ data }">
|
||||||
<svg-icon :icon-class="getFileTreeNodeIcon(data.label)" />
|
<div :class="`i-svg:${getFileTreeNodeIcon(data.label)}`" />
|
||||||
<span class="ml-1">{{ data.label }}</span>
|
<span class="ml-1">{{ data.label }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-tree>
|
</el-tree>
|
||||||
@@ -437,7 +437,7 @@ interface TreeNode {
|
|||||||
}
|
}
|
||||||
const treeData = ref<TreeNode[]>([]);
|
const treeData = ref<TreeNode[]>([]);
|
||||||
|
|
||||||
const queryFormRef = ref(ElForm);
|
const queryFormRef = ref();
|
||||||
const queryParams = reactive<TablePageQuery>({
|
const queryParams = reactive<TablePageQuery>({
|
||||||
pageNum: 1,
|
pageNum: 1,
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
@@ -535,7 +535,6 @@ const initSort = () => {
|
|||||||
ghostClass: "sortable-ghost", //拖拽样式
|
ghostClass: "sortable-ghost", //拖拽样式
|
||||||
handle: ".sortable-handle", //拖拽区域
|
handle: ".sortable-handle", //拖拽区域
|
||||||
easing: "cubic-bezier(1, 0, 0, 1)",
|
easing: "cubic-bezier(1, 0, 0, 1)",
|
||||||
onStart: (item: any) => {},
|
|
||||||
|
|
||||||
// 结束拖动事件
|
// 结束拖动事件
|
||||||
onEnd: (item: any) => {
|
onEnd: (item: any) => {
|
||||||
|
|||||||
@@ -1,209 +0,0 @@
|
|||||||
<!-- 线 + 柱混合图 -->
|
|
||||||
<template>
|
|
||||||
<el-card>
|
|
||||||
<template #header>
|
|
||||||
<div class="flex-x-between">
|
|
||||||
<div class="flex-y-center">
|
|
||||||
访问趋势
|
|
||||||
<el-tooltip effect="dark" content="点击试试下载" placement="bottom">
|
|
||||||
<el-icon
|
|
||||||
class="cursor-pointer hover:color-#4080FF ml-1"
|
|
||||||
name="el-icon-download"
|
|
||||||
@click="handleDownloadChart"
|
|
||||||
>
|
|
||||||
<Download />
|
|
||||||
</el-icon>
|
|
||||||
</el-tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-radio-group v-model="recentDaysRange" size="small" @change="handleDateRangeChange">
|
|
||||||
<el-radio-button label="近7天" :value="7" />
|
|
||||||
<el-radio-button label="近30天" :value="30" />
|
|
||||||
</el-radio-group>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div :id="id" :class="className" :style="{ height, width }" />
|
|
||||||
</el-card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import * as echarts from "echarts";
|
|
||||||
import LogAPI, { VisitTrendVO } from "@/api/system/log";
|
|
||||||
import { dayjs } from "element-plus";
|
|
||||||
|
|
||||||
// 日期范围
|
|
||||||
const recentDaysRange = ref(7);
|
|
||||||
// 图表对象
|
|
||||||
const chart: Ref<echarts.ECharts | null> = ref(null);
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
id: {
|
|
||||||
type: String,
|
|
||||||
default: "VisitTrend",
|
|
||||||
},
|
|
||||||
className: {
|
|
||||||
type: String,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
width: {
|
|
||||||
type: String,
|
|
||||||
default: "200px",
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
height: {
|
|
||||||
type: String,
|
|
||||||
default: "200px",
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/** 设置图表 */
|
|
||||||
const setChartOptions = (data: VisitTrendVO) => {
|
|
||||||
if (!chart.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
tooltip: {
|
|
||||||
trigger: "axis",
|
|
||||||
},
|
|
||||||
legend: {
|
|
||||||
data: ["浏览量(PV)", "IP"],
|
|
||||||
bottom: 0,
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
left: "1%",
|
|
||||||
right: "5%",
|
|
||||||
bottom: "10%",
|
|
||||||
containLabel: true,
|
|
||||||
},
|
|
||||||
xAxis: {
|
|
||||||
type: "category",
|
|
||||||
data: data.dates,
|
|
||||||
},
|
|
||||||
yAxis: {
|
|
||||||
type: "value",
|
|
||||||
splitLine: {
|
|
||||||
show: true,
|
|
||||||
lineStyle: {
|
|
||||||
type: "dashed",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: "浏览量(PV)",
|
|
||||||
type: "line",
|
|
||||||
data: data.pvList,
|
|
||||||
areaStyle: {
|
|
||||||
color: "rgba(64, 158, 255, 0.1)",
|
|
||||||
},
|
|
||||||
smooth: true,
|
|
||||||
itemStyle: {
|
|
||||||
color: "#4080FF",
|
|
||||||
},
|
|
||||||
lineStyle: {
|
|
||||||
color: "#4080FF",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "IP",
|
|
||||||
type: "line",
|
|
||||||
data: data.ipList,
|
|
||||||
areaStyle: {
|
|
||||||
color: "rgba(103, 194, 58, 0.1)",
|
|
||||||
},
|
|
||||||
smooth: true,
|
|
||||||
itemStyle: {
|
|
||||||
color: "#67C23A",
|
|
||||||
},
|
|
||||||
lineStyle: {
|
|
||||||
color: "#67C23A",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
chart.value.setOption(options);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 加载数据
|
|
||||||
const loadData = () => {
|
|
||||||
const endDate = new Date();
|
|
||||||
const startDate = dayjs()
|
|
||||||
.subtract(recentDaysRange.value - 1, "day")
|
|
||||||
.toDate();
|
|
||||||
|
|
||||||
const visitTrendQuery = {
|
|
||||||
startDate: dayjs(startDate).format("YYYY-MM-DD"),
|
|
||||||
endDate: dayjs(endDate).format("YYYY-MM-DD"),
|
|
||||||
};
|
|
||||||
|
|
||||||
LogAPI.getVisitTrend(visitTrendQuery).then((data) => {
|
|
||||||
setChartOptions(data);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 日期范围变化
|
|
||||||
const handleDateRangeChange = () => {
|
|
||||||
loadData();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 下载图表
|
|
||||||
const handleDownloadChart = () => {
|
|
||||||
if (!chart.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取画布图表地址信息
|
|
||||||
const img = new Image();
|
|
||||||
img.src = chart.value.getDataURL({
|
|
||||||
type: "png",
|
|
||||||
pixelRatio: 1,
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
});
|
|
||||||
// 当图片加载完成后,生成 URL 并下载
|
|
||||||
img.onload = () => {
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
canvas.width = img.width;
|
|
||||||
canvas.height = img.height;
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
if (ctx) {
|
|
||||||
ctx.drawImage(img, 0, 0, img.width, img.height);
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.download = "访问趋势.png";
|
|
||||||
link.href = canvas.toDataURL("image/png", 0.9);
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
link.remove();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// 窗口大小变化时,重置图表大小
|
|
||||||
const handleResize = () => {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (chart.value) {
|
|
||||||
chart.value.resize();
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 初始化图表
|
|
||||||
onMounted(() => {
|
|
||||||
chart.value = markRaw(echarts.init(document.getElementById(props.id) as HTMLDivElement));
|
|
||||||
loadData();
|
|
||||||
|
|
||||||
window.addEventListener("resize", handleResize);
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
window.removeEventListener("resize", handleResize);
|
|
||||||
});
|
|
||||||
|
|
||||||
onActivated(() => {
|
|
||||||
handleResize();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<style lang="scss" scoped></style>
|
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
<el-col :span="18" :xs="24">
|
<el-col :span="18" :xs="24">
|
||||||
<div class="flex-x-start">
|
<div class="flex-x-start">
|
||||||
<img
|
<img
|
||||||
class="wh-80px rounded-full"
|
class="w80px h80px rounded-full"
|
||||||
:src="userStore.userInfo.avatar + '?imageView2/1/w/80/h/80'"
|
:src="userStore.userInfo.avatar + '?imageView2/1/w/80/h/80'"
|
||||||
/>
|
/>
|
||||||
<div class="ml-5">
|
<div class="ml-5">
|
||||||
@@ -27,15 +27,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<el-link href="https://gitee.com/youlaiorg/vue3-element-admin" target="_blank">
|
<el-link href="https://gitee.com/youlaiorg/vue3-element-admin" target="_blank">
|
||||||
<SvgIcon icon-class="gitee" class="text-lg color-#f76560" />
|
<div class="i-svg:gitee text-lg color-#F76560" />
|
||||||
</el-link>
|
</el-link>
|
||||||
<el-divider direction="vertical" />
|
<el-divider direction="vertical" />
|
||||||
<el-link href="https://github.com/youlaitech/vue3-element-admin" target="_blank">
|
<el-link href="https://github.com/youlaitech/vue3-element-admin" target="_blank">
|
||||||
<SvgIcon icon-class="github" class="text-lg color-#4080ff" />
|
<div class="i-svg:github text-lg color-#4080FF" />
|
||||||
</el-link>
|
</el-link>
|
||||||
<el-divider direction="vertical" />
|
<el-divider direction="vertical" />
|
||||||
<el-link href="https://gitcode.com/youlai/vue3-element-admin" target="_blank">
|
<el-link href="https://gitcode.com/youlai/vue3-element-admin" target="_blank">
|
||||||
<SvgIcon icon-class="gitcode" class="text-lg color-#ff9a2e" />
|
<div class="i-svg:gitcode text-lg color-#FF9A2E" />
|
||||||
</el-link>
|
</el-link>
|
||||||
</div>
|
</div>
|
||||||
</el-col>
|
</el-col>
|
||||||
@@ -47,18 +47,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<el-link href="https://juejin.cn/post/7228990409909108793" target="_blank">
|
<el-link href="https://juejin.cn/post/7228990409909108793" target="_blank">
|
||||||
<SvgIcon icon-class="juejin" class="text-lg" />
|
<div class="i-svg:juejin text-lg" />
|
||||||
</el-link>
|
</el-link>
|
||||||
<el-divider direction="vertical" />
|
<el-divider direction="vertical" />
|
||||||
<el-link
|
<el-link
|
||||||
href="https://youlai.blog.csdn.net/article/details/130191394"
|
href="https://youlai.blog.csdn.net/article/details/130191394"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<SvgIcon icon-class="csdn" class="text-lg" />
|
<div class="i-svg:csdn text-lg" />
|
||||||
</el-link>
|
</el-link>
|
||||||
<el-divider direction="vertical" />
|
<el-divider direction="vertical" />
|
||||||
<el-link href="https://www.cnblogs.com/haoxianrui/p/17331952.html" target="_blank">
|
<el-link href="https://www.cnblogs.com/haoxianrui/p/17331952.html" target="_blank">
|
||||||
<SvgIcon icon-class="cnblogs" class="text-lg" />
|
<div class="i-svg:cnblogs text-lg" />
|
||||||
</el-link>
|
</el-link>
|
||||||
</div>
|
</div>
|
||||||
</el-col>
|
</el-col>
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<el-link href="https://www.bilibili.com/video/BV1eFUuYyEFj" target="_blank">
|
<el-link href="https://www.bilibili.com/video/BV1eFUuYyEFj" target="_blank">
|
||||||
<SvgIcon icon-class="bilibili" class="text-lg" />
|
<div class="i-svg:bilibili text-lg" />
|
||||||
</el-link>
|
</el-link>
|
||||||
</div>
|
</div>
|
||||||
</el-col>
|
</el-col>
|
||||||
@@ -116,7 +116,11 @@
|
|||||||
<div class="flex-y-center">
|
<div class="flex-y-center">
|
||||||
<span class="text-lg">{{ visitStatsData.todayUvCount }}</span>
|
<span class="text-lg">{{ visitStatsData.todayUvCount }}</span>
|
||||||
<span
|
<span
|
||||||
:class="['text-xs', 'ml-2', getGrowthRateClass(visitStatsData.uvGrowthRate)]"
|
:class="[
|
||||||
|
'text-xs',
|
||||||
|
'ml-2',
|
||||||
|
computeGrowthRateClass(visitStatsData.uvGrowthRate),
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<el-icon>
|
<el-icon>
|
||||||
<Top v-if="visitStatsData.uvGrowthRate > 0" />
|
<Top v-if="visitStatsData.uvGrowthRate > 0" />
|
||||||
@@ -125,7 +129,7 @@
|
|||||||
{{ formatGrowthRate(visitStatsData.uvGrowthRate) }}
|
{{ formatGrowthRate(visitStatsData.uvGrowthRate) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<svg-icon icon-class="visitor" size="2em" />
|
<div class="i-svg:visitor w-8 h-8" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-x-between mt-2 text-sm text-gray">
|
<div class="flex-x-between mt-2 text-sm text-gray">
|
||||||
@@ -172,7 +176,11 @@
|
|||||||
<div class="flex-y-center">
|
<div class="flex-y-center">
|
||||||
<span class="text-lg">{{ visitStatsData.todayPvCount }}</span>
|
<span class="text-lg">{{ visitStatsData.todayPvCount }}</span>
|
||||||
<span
|
<span
|
||||||
:class="['text-xs', 'ml-2', getGrowthRateClass(visitStatsData.pvGrowthRate)]"
|
:class="[
|
||||||
|
'text-xs',
|
||||||
|
'ml-2',
|
||||||
|
computeGrowthRateClass(visitStatsData.pvGrowthRate),
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<el-icon>
|
<el-icon>
|
||||||
<Top v-if="visitStatsData.pvGrowthRate > 0" />
|
<Top v-if="visitStatsData.pvGrowthRate > 0" />
|
||||||
@@ -181,7 +189,7 @@
|
|||||||
{{ formatGrowthRate(visitStatsData.pvGrowthRate) }}
|
{{ formatGrowthRate(visitStatsData.pvGrowthRate) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<svg-icon icon-class="browser" size="2em" />
|
<div class="i-svg:browser w-8 h-8" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-x-between mt-2 text-sm text-gray">
|
<div class="flex-x-between mt-2 text-sm text-gray">
|
||||||
@@ -197,7 +205,18 @@
|
|||||||
<el-row :gutter="10" class="mt-5">
|
<el-row :gutter="10" class="mt-5">
|
||||||
<!-- 访问趋势统计图 -->
|
<!-- 访问趋势统计图 -->
|
||||||
<el-col :xs="24" :span="16">
|
<el-col :xs="24" :span="16">
|
||||||
<VisitTrend id="VisitTrend" width="100%" height="400px" />
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex-x-between">
|
||||||
|
<span>访问趋势</span>
|
||||||
|
<el-radio-group v-model="visitTrendDateRange" size="small">
|
||||||
|
<el-radio-button label="近7天" :value="7" />
|
||||||
|
<el-radio-button label="近30天" :value="30" />
|
||||||
|
</el-radio-group>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<ECharts :options="visitTrendChartOptions" height="400px" />
|
||||||
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
<!-- 通知公告 -->
|
<!-- 通知公告 -->
|
||||||
<el-col :xs="24" :span="8">
|
<el-col :xs="24" :span="8">
|
||||||
@@ -206,7 +225,7 @@
|
|||||||
<div class="flex-x-between">
|
<div class="flex-x-between">
|
||||||
<div class="flex-y-center">通知公告</div>
|
<div class="flex-y-center">通知公告</div>
|
||||||
<el-link type="primary">
|
<el-link type="primary">
|
||||||
<span class="text-xs" @click="handleViewMoreNotice">查看更多</span>
|
<span class="text-xs" @click="navigateToNoticePage">查看更多</span>
|
||||||
<el-icon class="text-xs"><ArrowRight /></el-icon>
|
<el-icon class="text-xs"><ArrowRight /></el-icon>
|
||||||
</el-link>
|
</el-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -218,7 +237,7 @@
|
|||||||
<el-text truncated class="!mx-2 flex-1 !text-xs !text-gray">
|
<el-text truncated class="!mx-2 flex-1 !text-xs !text-gray">
|
||||||
{{ item.title }}
|
{{ item.title }}
|
||||||
</el-text>
|
</el-text>
|
||||||
<el-link @click="handleOpenNoticeDetail(item.id)">
|
<el-link @click="openNoticeDetail(item.id)">
|
||||||
<el-icon class="text-sm"><View /></el-icon>
|
<el-icon class="text-sm"><View /></el-icon>
|
||||||
</el-link>
|
</el-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -237,38 +256,43 @@ defineOptions({
|
|||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
import VisitTrend from "./components/visit-trend.vue";
|
import { dayjs } from "element-plus";
|
||||||
|
|
||||||
import router from "@/router";
|
import router from "@/router";
|
||||||
|
import LogAPI, { VisitStatsVO, VisitTrendVO } from "@/api/system/log";
|
||||||
import LogAPI, { VisitStatsVO } from "@/api/system/log";
|
|
||||||
import NoticeAPI, { NoticePageVO } from "@/api/system/notice";
|
import NoticeAPI, { NoticePageVO } from "@/api/system/notice";
|
||||||
|
|
||||||
import { useUserStore } from "@/store/modules/user";
|
import { useUserStore } from "@/store/modules/user";
|
||||||
import { formatGrowthRate } from "@/utils";
|
import { formatGrowthRate } from "@/utils";
|
||||||
|
|
||||||
|
const userStore = useUserStore();
|
||||||
|
|
||||||
const noticeDetailRef = ref();
|
const noticeDetailRef = ref();
|
||||||
|
|
||||||
|
// 当前通知公告列表
|
||||||
const notices = ref<NoticePageVO[]>([]);
|
const notices = ref<NoticePageVO[]>([]);
|
||||||
|
|
||||||
const userStore = useUserStore();
|
// 当前时间(用于计算问候语)
|
||||||
const date: Date = new Date();
|
const currentDate = new Date();
|
||||||
|
|
||||||
|
// 问候语:根据当前小时返回不同问候语
|
||||||
const greetings = computed(() => {
|
const greetings = computed(() => {
|
||||||
const hours = date.getHours();
|
const hours = currentDate.getHours();
|
||||||
|
const nickname = userStore.userInfo.nickname;
|
||||||
if (hours >= 6 && hours < 8) {
|
if (hours >= 6 && hours < 8) {
|
||||||
return "晨起披衣出草堂,轩窗已自喜微凉🌅!";
|
return "晨起披衣出草堂,轩窗已自喜微凉🌅!";
|
||||||
} else if (hours >= 8 && hours < 12) {
|
} else if (hours >= 8 && hours < 12) {
|
||||||
return "上午好," + userStore.userInfo.nickname + "!";
|
return `上午好,${nickname}!`;
|
||||||
} else if (hours >= 12 && hours < 18) {
|
} else if (hours >= 12 && hours < 18) {
|
||||||
return "下午好," + userStore.userInfo.nickname + "!";
|
return `下午好,${nickname}!`;
|
||||||
} else if (hours >= 18 && hours < 24) {
|
} else if (hours >= 18 && hours < 24) {
|
||||||
return "晚上好," + userStore.userInfo.nickname + "!";
|
return `晚上好,${nickname}!`;
|
||||||
} else {
|
} else {
|
||||||
return "偷偷向银河要了一把碎星,只等你闭上眼睛撒入你的梦中,晚安🌛!";
|
return "偷偷向银河要了一把碎星,只等你闭上眼睛撒入你的梦中,晚安🌛!";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 访客统计数据加载状态
|
||||||
const visitStatsLoading = ref(true);
|
const visitStatsLoading = ref(true);
|
||||||
|
// 访客统计数据
|
||||||
const visitStatsData = ref<VisitStatsVO>({
|
const visitStatsData = ref<VisitStatsVO>({
|
||||||
todayUvCount: 0,
|
todayUvCount: 0,
|
||||||
uvGrowthRate: 0,
|
uvGrowthRate: 0,
|
||||||
@@ -278,8 +302,15 @@ const visitStatsData = ref<VisitStatsVO>({
|
|||||||
totalPvCount: 0,
|
totalPvCount: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 加载访问统计数据
|
// 访问趋势日期范围(单位:天)
|
||||||
const loadVisitStatsData = async () => {
|
const visitTrendDateRange = ref(7);
|
||||||
|
// 访问趋势图表配置
|
||||||
|
const visitTrendChartOptions = ref();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取访客统计数据
|
||||||
|
*/
|
||||||
|
const fetchVisitStatsData = () => {
|
||||||
LogAPI.getVisitStats()
|
LogAPI.getVisitStats()
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
visitStatsData.value = data;
|
visitStatsData.value = data;
|
||||||
@@ -289,12 +320,102 @@ const loadVisitStatsData = async () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 根据增长率获取样式
|
/**
|
||||||
const getGrowthRateClass = (growthRate?: number): string => {
|
* 获取访问趋势数据,并更新图表配置
|
||||||
|
*/
|
||||||
|
const fetchVisitTrendData = () => {
|
||||||
|
const startDate = dayjs()
|
||||||
|
.subtract(visitTrendDateRange.value - 1, "day")
|
||||||
|
.toDate();
|
||||||
|
const endDate = new Date();
|
||||||
|
|
||||||
|
LogAPI.getVisitTrend({
|
||||||
|
startDate: dayjs(startDate).format("YYYY-MM-DD"),
|
||||||
|
endDate: dayjs(endDate).format("YYYY-MM-DD"),
|
||||||
|
}).then((data) => {
|
||||||
|
updateVisitTrendChartOptions(data);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新访问趋势图表的配置项
|
||||||
|
*
|
||||||
|
* @param data - 访问趋势数据
|
||||||
|
*/
|
||||||
|
const updateVisitTrendChartOptions = (data: VisitTrendVO) => {
|
||||||
|
console.log("Updating visit trend chart options");
|
||||||
|
|
||||||
|
visitTrendChartOptions.value = {
|
||||||
|
tooltip: {
|
||||||
|
trigger: "axis",
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
data: ["浏览量(PV)", "访客数(UV)"],
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: "1%",
|
||||||
|
right: "5%",
|
||||||
|
bottom: "10%",
|
||||||
|
containLabel: true,
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: "category",
|
||||||
|
data: data.dates,
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: "value",
|
||||||
|
splitLine: {
|
||||||
|
show: true,
|
||||||
|
lineStyle: {
|
||||||
|
type: "dashed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: "浏览量(PV)",
|
||||||
|
type: "line",
|
||||||
|
data: data.pvList,
|
||||||
|
areaStyle: {
|
||||||
|
color: "rgba(64, 158, 255, 0.1)",
|
||||||
|
},
|
||||||
|
smooth: true,
|
||||||
|
itemStyle: {
|
||||||
|
color: "#4080FF",
|
||||||
|
},
|
||||||
|
lineStyle: {
|
||||||
|
color: "#4080FF",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "访客数(UV)",
|
||||||
|
type: "line",
|
||||||
|
data: data.ipList,
|
||||||
|
areaStyle: {
|
||||||
|
color: "rgba(103, 194, 58, 0.1)",
|
||||||
|
},
|
||||||
|
smooth: true,
|
||||||
|
itemStyle: {
|
||||||
|
color: "#67C23A",
|
||||||
|
},
|
||||||
|
lineStyle: {
|
||||||
|
color: "#67C23A",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据增长率计算对应的 CSS 类名
|
||||||
|
*
|
||||||
|
* @param growthRate - 增长率数值
|
||||||
|
*/
|
||||||
|
const computeGrowthRateClass = (growthRate?: number): string => {
|
||||||
if (!growthRate) {
|
if (!growthRate) {
|
||||||
return "color-[--el-color-info]";
|
return "color-[--el-color-info]";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (growthRate > 0) {
|
if (growthRate > 0) {
|
||||||
return "color-[--el-color-danger]";
|
return "color-[--el-color-danger]";
|
||||||
} else if (growthRate < 0) {
|
} else if (growthRate < 0) {
|
||||||
@@ -304,25 +425,45 @@ const getGrowthRateClass = (growthRate?: number): string => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadMyNotice = () => {
|
/**
|
||||||
|
* 获取当前用户的通知公告数据
|
||||||
|
*/
|
||||||
|
const fetchMyNotices = () => {
|
||||||
NoticeAPI.getMyNoticePage({ pageNum: 1, pageSize: 10 }).then((data) => {
|
NoticeAPI.getMyNoticePage({ pageNum: 1, pageSize: 10 }).then((data) => {
|
||||||
notices.value = data.list;
|
notices.value = data.list;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 查看更多
|
/**
|
||||||
function handleViewMoreNotice() {
|
* 跳转至通知公告详情页面(查看更多通知)
|
||||||
|
*/
|
||||||
|
function navigateToNoticePage() {
|
||||||
router.push({ path: "/myNotice" });
|
router.push({ path: "/myNotice" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 打开通知公告
|
/**
|
||||||
function handleOpenNoticeDetail(id: string) {
|
* 打开指定通知详情
|
||||||
|
*
|
||||||
|
* @param id - 通知 ID
|
||||||
|
*/
|
||||||
|
function openNoticeDetail(id: string) {
|
||||||
noticeDetailRef.value.openNotice(id);
|
noticeDetailRef.value.openNotice(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 监听访问趋势日期范围的变化,重新获取趋势数据
|
||||||
|
watch(
|
||||||
|
() => visitTrendDateRange.value,
|
||||||
|
(newVal) => {
|
||||||
|
console.log("Visit trend date range changed:", newVal);
|
||||||
|
fetchVisitTrendData();
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 组件挂载后加载访客统计数据和通知公告数据
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadVisitStatsData();
|
fetchVisitStatsData();
|
||||||
loadMyNotice();
|
fetchMyNotices();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<copy-button :text="generateIconCode(item)">
|
<copy-button :text="generateIconCode(item)">
|
||||||
<el-tooltip effect="dark" :content="generateIconCode(item)" placement="top">
|
<el-tooltip effect="dark" :content="generateIconCode(item)" placement="top">
|
||||||
<div class="icon-item">
|
<div class="icon-item">
|
||||||
<svg-icon :icon-class="item" />
|
<div :class="`i-svg:${item}`" />
|
||||||
<span>{{ item }}</span>
|
<span>{{ item }}</span>
|
||||||
</div>
|
</div>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
@@ -36,7 +36,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import SvgIcon from "@/components/SvgIcon/index.vue";
|
|
||||||
import * as ElementPlusIconsVue from "@element-plus/icons-vue";
|
import * as ElementPlusIconsVue from "@element-plus/icons-vue";
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
@@ -87,7 +86,7 @@ const svg_icons: string[] = [
|
|||||||
const icons = ref(ElementPlusIconsVue);
|
const icons = ref(ElementPlusIconsVue);
|
||||||
|
|
||||||
function generateIconCode(symbol: any) {
|
function generateIconCode(symbol: any) {
|
||||||
return `<svg-icon icon-class="${symbol}" />`;
|
return `<div class="i-svg:${symbol}" />`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateElementIconCode(symbol: any) {
|
function generateElementIconCode(symbol: any) {
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ const handleToFile = async () => {
|
|||||||
const file = dataURLtoFile(canvas.value.toDataURL(), "签名.png");
|
const file = dataURLtoFile(canvas.value.toDataURL(), "签名.png");
|
||||||
|
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
const data = await FileAPI.upload(file);
|
const data = await FileAPI.uploadFile(file);
|
||||||
handleClearSign();
|
handleClearSign();
|
||||||
imgUrl.value = data.url;
|
imgUrl.value = data.url;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -97,7 +97,14 @@ const selectConfig: ISelectConfig = {
|
|||||||
{ label: "编号", align: "center", prop: "id", width: 100 },
|
{ label: "编号", align: "center", prop: "id", width: 100 },
|
||||||
{ label: "用户名", align: "center", prop: "username" },
|
{ label: "用户名", align: "center", prop: "username" },
|
||||||
{ label: "用户昵称", align: "center", prop: "nickname", width: 120 },
|
{ label: "用户昵称", align: "center", prop: "nickname", width: 120 },
|
||||||
{ label: "性别", align: "center", prop: "genderLabel", width: 100 },
|
{
|
||||||
|
label: "性别",
|
||||||
|
align: "center",
|
||||||
|
prop: "gender",
|
||||||
|
width: 100,
|
||||||
|
templet: "custom",
|
||||||
|
slotName: "gender",
|
||||||
|
},
|
||||||
{ label: "部门", align: "center", prop: "deptName", width: 120 },
|
{ label: "部门", align: "center", prop: "deptName", width: 120 },
|
||||||
{ label: "手机号码", align: "center", prop: "mobile", width: 120 },
|
{ label: "手机号码", align: "center", prop: "mobile", width: 120 },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
<!-- 列表选择器示例 -->
|
<!-- 列表选择器示例 -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import selectConfig from "./config/select";
|
import selectConfig from "./config/select";
|
||||||
|
import { useDictStore } from "@/store";
|
||||||
|
const dictStore = useDictStore();
|
||||||
interface IUser {
|
interface IUser {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
nickname: string;
|
nickname: string;
|
||||||
mobile: string;
|
mobile: string;
|
||||||
genderLabel: string;
|
gender: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
status: number;
|
status: number;
|
||||||
@@ -20,8 +21,11 @@ function handleConfirm(data: IUser[]) {
|
|||||||
selectedUser.value = data[0];
|
selectedUser.value = data[0];
|
||||||
}
|
}
|
||||||
const text = computed(() => {
|
const text = computed(() => {
|
||||||
|
// 获取字典数据
|
||||||
|
const dictData = dictStore.getDictionary("gender");
|
||||||
|
const genderLabel = dictData.find((item: any) => item.value == selectedUser.value?.gender)?.label;
|
||||||
return selectedUser.value
|
return selectedUser.value
|
||||||
? `${selectedUser.value.username} - ${selectedUser.value.genderLabel} - ${selectedUser.value.deptName}`
|
? `${selectedUser.value.username} - ${genderLabel} - ${selectedUser.value.deptName}`
|
||||||
: "";
|
: "";
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -42,6 +46,9 @@ const text = computed(() => {
|
|||||||
{{ scope.row[scope.prop] == 1 ? "启用" : "禁用" }}
|
{{ scope.row[scope.prop] == 1 ? "启用" : "禁用" }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
|
<template #gender="scope">
|
||||||
|
<DictLabel v-model="scope.row.gender" code="gender" />
|
||||||
|
</template>
|
||||||
</table-select>
|
</table-select>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -11,216 +11,30 @@
|
|||||||
</el-link>
|
</el-link>
|
||||||
|
|
||||||
<el-form>
|
<el-form>
|
||||||
<el-form-item label="绑定值">
|
<el-form-item label="单图上传">
|
||||||
{{ picUrl }}
|
<SingleImageUpload v-model="picUrl" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="图片上传">
|
|
||||||
<ImageUpload v-model="picUrl" :maxSize="10" />
|
<el-form-item label="多图上传">
|
||||||
</el-form-item>
|
<MultiImageUpload v-model="picUrls" />
|
||||||
<el-form-item label="参数说明">
|
|
||||||
<el-table :data="imageUploadArgData" border>
|
|
||||||
<el-table-column prop="argsName" label="参数名称" width="300" />
|
|
||||||
<el-table-column prop="type" label="参数类型" width="200" />
|
|
||||||
<el-table-column prop="default" label="默认值" width="400" />
|
|
||||||
<el-table-column prop="desc" label="描述" width="300" />
|
|
||||||
</el-table>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="文件上传">
|
<el-form-item label="文件上传">
|
||||||
<FileUpload v-model="fileUrls" />
|
<FileUpload v-model="fileUrls" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="参数说明">
|
|
||||||
<el-table :data="fileUploadArgData" border>
|
|
||||||
<el-table-column prop="argsName" label="参数名称" width="300" />
|
|
||||||
<el-table-column prop="type" label="参数类型" width="200" />
|
|
||||||
<el-table-column prop="default" label="默认值" width="400" />
|
|
||||||
<el-table-column prop="desc" label="描述" width="300" />
|
|
||||||
</el-table>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import MultiImageUpload from "@/components/Upload/MultiImageUpload.vue";
|
||||||
|
|
||||||
// 单图
|
// 单图
|
||||||
const picUrl = ref("https://s2.loli.net/2023/05/24/yNsxFC8rLHMZQcK.jpg");
|
const picUrl = ref("https://s2.loli.net/2023/05/24/yNsxFC8rLHMZQcK.jpg");
|
||||||
|
const picUrls = ref(["https://s2.loli.net/2023/05/24/yNsxFC8rLHMZQcK.jpg"]);
|
||||||
const imageUploadArgData = [
|
|
||||||
{
|
|
||||||
argsName: "v-model",
|
|
||||||
type: "[Array,String]",
|
|
||||||
default: "[] | ''",
|
|
||||||
desc: "已经上传的图片数组,单张图片时为String",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
argsName: "action",
|
|
||||||
type: "String",
|
|
||||||
default: "FileAPI.uploadUrl",
|
|
||||||
desc: "文件上传地址",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
argsName: "headers",
|
|
||||||
type: "Object",
|
|
||||||
default: "{Authorization: localStorage.getItem(TOKEN_KEY),}",
|
|
||||||
desc: "上传请求头",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
argsName: "data",
|
|
||||||
type: "Object",
|
|
||||||
default: "{}",
|
|
||||||
desc: "请求携带的额外参数",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
argsName: "name",
|
|
||||||
type: "String",
|
|
||||||
default: "file",
|
|
||||||
desc: "上传文件的参数名",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
argsName: "limit",
|
|
||||||
type: "Number",
|
|
||||||
default: 1,
|
|
||||||
desc: "上传最大的图片数量,多张图片时填写最大上传数量,默认单张图片",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
argsName: "show-del-btn",
|
|
||||||
type: "Boolean",
|
|
||||||
default: true,
|
|
||||||
desc: "是否显示删除按钮",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
argsName: "show-upload-btn",
|
|
||||||
type: "Boolean",
|
|
||||||
default: true,
|
|
||||||
desc: "是否显示上传按钮",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
argsName: "max-size",
|
|
||||||
type: "Number",
|
|
||||||
default: "10",
|
|
||||||
desc: "单个图片上传大小限制(单位MB)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
argsName: "accept",
|
|
||||||
type: "String",
|
|
||||||
default: "image/*",
|
|
||||||
desc: "上传文件类型",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
argsName: "supportFileType",
|
|
||||||
type: "Array",
|
|
||||||
default: "[]",
|
|
||||||
desc: "支持的文件类型,默认支持所有图片格式,eg:['png','jpg','jpeg','gif']",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
argsName: "isSyncDelete",
|
|
||||||
type: "Boolean",
|
|
||||||
default: "true",
|
|
||||||
desc: "是否同步删除服务端文件(默认是,如果为否,则只会删除当前上传的图片,已经上传到服务端到图片不会删除)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
argsName: "style",
|
|
||||||
type: "Object",
|
|
||||||
default: "{width: '130px',height: '130px'}",
|
|
||||||
desc: "上传组件的样式",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const fileUrls = ref([
|
const fileUrls = ref([
|
||||||
{
|
"https://s2.loli.net/2023/05/24/yNsxFC8rLHMZQcK.jpg",
|
||||||
name: "file one.jpg",
|
"https://s2.loli.net/2023/05/24/RuHFMwW4rG5lIqs.jpg",
|
||||||
url: "https://s2.loli.net/2023/05/24/yNsxFC8rLHMZQcK.jpg",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "file two.jpg",
|
|
||||||
url: "https://s2.loli.net/2023/05/24/RuHFMwW4rG5lIqs.jpg",
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const fileUploadArgData = [
|
|
||||||
{
|
|
||||||
argsName: "v-model",
|
|
||||||
type: "Arrays",
|
|
||||||
default: "[]",
|
|
||||||
desc: "已经上传的文件数组",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
argsName: "action",
|
|
||||||
type: "String",
|
|
||||||
default: "FileAPI.uploadUrl",
|
|
||||||
desc: "文件上传地址",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
argsName: "limit",
|
|
||||||
type: "Number",
|
|
||||||
default: 10,
|
|
||||||
desc: "上传最大的文件数量",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
argsName: "show-del-btn",
|
|
||||||
type: "Boolean",
|
|
||||||
default: true,
|
|
||||||
desc: "是否显示删除按钮",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
argsName: "show-upload-btn",
|
|
||||||
type: "Boolean",
|
|
||||||
default: true,
|
|
||||||
desc: "是否显示上传按钮",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
argsName: "max-size",
|
|
||||||
type: "Number",
|
|
||||||
default: "10",
|
|
||||||
desc: "单个文件上传大小限制(单位MB)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
argsName: "accept",
|
|
||||||
type: "String",
|
|
||||||
default: "*",
|
|
||||||
desc: "上传文件类型",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
argsName: "upload-btn-text",
|
|
||||||
type: "String",
|
|
||||||
default: "上传文件",
|
|
||||||
desc: "上传按钮文本",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
argsName: "show-tip",
|
|
||||||
type: "Boolean",
|
|
||||||
default: false,
|
|
||||||
desc: "是否显示提示",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
argsName: "tip",
|
|
||||||
type: "String",
|
|
||||||
default: '""',
|
|
||||||
desc: "提示文本",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
argsName: "headers",
|
|
||||||
type: "Object",
|
|
||||||
default: "{Authorization: localStorage.getItem(TOKEN_KEY),}",
|
|
||||||
desc: "提示文本类型",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
argsName: "data",
|
|
||||||
type: "Object",
|
|
||||||
default: "{}",
|
|
||||||
desc: "请求携带的额外参数",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
argsName: "name",
|
|
||||||
type: "String",
|
|
||||||
default: "file",
|
|
||||||
desc: "上传文件的参数名",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
argsName: "style",
|
|
||||||
type: "Object",
|
|
||||||
default: "{width:'300px'}",
|
|
||||||
desc: "上传组件的样式",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<!-- wangEditor富文本编辑器示例 -->
|
<!-- wangEditor富文本编辑器示例 -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Editor from "@/components/WangEditor/index.vue";
|
import WangEditor from "@/components/WangEditor/index.vue";
|
||||||
|
|
||||||
const value = ref("初始内容");
|
const value = ref("初始化内容");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -15,6 +15,10 @@ const value = ref("初始内容");
|
|||||||
>
|
>
|
||||||
示例源码 请点击>>>>
|
示例源码 请点击>>>>
|
||||||
</el-link>
|
</el-link>
|
||||||
<editor v-model="value" style="z-index: 99999; height: calc(100vh - 180px)" />
|
<WangEditor v-model="value" height="400px" />
|
||||||
|
|
||||||
|
<div style="margin-top: 10px">
|
||||||
|
<textarea v-model="value" readonly style="width: 100%; height: 200px; outline: none" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-card>
|
<el-card>
|
||||||
<el-row>
|
<el-row>
|
||||||
<el-col :span="16">
|
<el-col :span="18">
|
||||||
<el-input v-model="socketEndpoint" class="w-220px" />
|
<el-input v-model="socketEndpoint" style="width: 200px" />
|
||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
type="primary"
|
||||||
class="ml-5"
|
class="ml-5"
|
||||||
@@ -26,10 +26,10 @@
|
|||||||
断开
|
断开
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :span="8" class="text-right">
|
<el-col :span="6" class="text-right">
|
||||||
连接状态:
|
连接状态:
|
||||||
<el-tag v-if="isConnected" class="ml-2" type="success">已连接</el-tag>
|
<el-tag v-if="isConnected" type="success">已连接</el-tag>
|
||||||
<el-tag v-else class="ml-2" type="info">已断开</el-tag>
|
<el-tag v-else type="info">已断开</el-tag>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
</el-card>
|
</el-card>
|
||||||
@@ -62,28 +62,31 @@
|
|||||||
<!-- 消息接收显示部分 -->
|
<!-- 消息接收显示部分 -->
|
||||||
<el-col :span="12">
|
<el-col :span="12">
|
||||||
<el-card>
|
<el-card>
|
||||||
<div class="message-container">
|
<div class="chat-messages-wrapper">
|
||||||
<div
|
<div
|
||||||
v-for="(message, index) in messages"
|
v-for="(message, index) in messages"
|
||||||
:key="index"
|
:key="index"
|
||||||
:class="{
|
:class="[
|
||||||
'tip-message': message.type === 'tip',
|
message.type === 'tip' ? 'system-notice' : 'chat-message',
|
||||||
message: message.type !== 'tip',
|
{
|
||||||
'message--sent': message.sender === userStore.userInfo.username,
|
'chat-message--sent': message.sender === userStore.userInfo.username,
|
||||||
'message--received': message.sender !== userStore.userInfo.username,
|
'chat-message--received': message.sender !== userStore.userInfo.username,
|
||||||
}"
|
},
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<div v-if="message.type != 'tip'" class="message-content">
|
<template v-if="message.type != 'tip'">
|
||||||
<div
|
<div class="chat-message__content">
|
||||||
:class="{
|
<div
|
||||||
'message-sender': message.sender === userStore.userInfo.username,
|
:class="{
|
||||||
'message-receiver': message.sender !== userStore.userInfo.username,
|
'chat-message__sender': message.sender === userStore.userInfo.username,
|
||||||
}"
|
'chat-message__receiver': message.sender !== userStore.userInfo.username,
|
||||||
>
|
}"
|
||||||
{{ message.sender }}
|
>
|
||||||
|
{{ message.sender }}
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-600">{{ message.content }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="color-#333">{{ message.content }}</div>
|
</template>
|
||||||
</div>
|
|
||||||
<div v-else>{{ message.content }}</div>
|
<div v-else>{{ message.content }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,98 +97,77 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Client } from "@stomp/stompjs";
|
import { useStomp } from "@/hooks/useStomp";
|
||||||
|
|
||||||
import { useUserStoreHook } from "@/store/modules/user";
|
import { useUserStoreHook } from "@/store/modules/user";
|
||||||
import { getToken } from "@/utils/auth";
|
|
||||||
|
|
||||||
const userStore = useUserStoreHook();
|
const userStore = useUserStoreHook();
|
||||||
const isConnected = ref(false);
|
// 用于手动调整 WebSocket 地址
|
||||||
const socketEndpoint = ref(import.meta.env.VITE_APP_WS_ENDPOINT);
|
const socketEndpoint = ref(import.meta.env.VITE_APP_WS_ENDPOINT);
|
||||||
|
// 同步连接状态
|
||||||
const receiver = ref("root");
|
|
||||||
|
|
||||||
interface MessageType {
|
interface MessageType {
|
||||||
type?: string;
|
type?: string;
|
||||||
sender?: string;
|
sender?: string;
|
||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const messages = ref<MessageType[]>([]);
|
const messages = ref<MessageType[]>([]);
|
||||||
|
// 广播消息内容
|
||||||
|
const topicMessage = ref("亲爱的朋友们,系统已恢复最新状态。");
|
||||||
|
// 点对点消息内容(默认示例)
|
||||||
|
const queneMessage = ref("Hi, " + userStore.userInfo.username + " 这里是点对点消息示例!");
|
||||||
|
const receiver = ref("root");
|
||||||
|
|
||||||
const topicMessage = ref("亲爱的大冤种们,由于一只史诗级的BUG,系统版本已经被迫回退到了0.0.1。"); // 广播消息
|
// 调用 useStomp hook,默认使用 socketEndpoint 和 token(此处用 getAccessToken())
|
||||||
|
const { isConnected, connect, subscribe, disconnect, client } = useStomp({
|
||||||
|
debug: true,
|
||||||
|
});
|
||||||
|
|
||||||
const queneMessage = ref(
|
watch(
|
||||||
"hi , " + receiver.value + " , 我是" + userStore.userInfo.username + " , 想和你交个朋友 ! "
|
() => isConnected.value,
|
||||||
);
|
(connected) => {
|
||||||
|
if (connected) {
|
||||||
let stompClient: Client;
|
// 连接成功后,订阅广播和点对点消息主题
|
||||||
|
subscribe("/topic/notice", (res) => {
|
||||||
function connectWebSocket() {
|
|
||||||
stompClient = new Client({
|
|
||||||
brokerURL: socketEndpoint.value,
|
|
||||||
connectHeaders: {
|
|
||||||
Authorization: getToken(),
|
|
||||||
},
|
|
||||||
debug: (str: any) => {
|
|
||||||
console.log(str);
|
|
||||||
},
|
|
||||||
onConnect: () => {
|
|
||||||
console.log("连接成功");
|
|
||||||
isConnected.value = true;
|
|
||||||
messages.value.push({
|
|
||||||
sender: "Server",
|
|
||||||
content: "Websocket 已连接",
|
|
||||||
type: "tip",
|
|
||||||
});
|
|
||||||
|
|
||||||
stompClient.subscribe("/topic/notice", (res: any) => {
|
|
||||||
messages.value.push({
|
messages.value.push({
|
||||||
sender: "Server",
|
sender: "Server",
|
||||||
content: res.body,
|
content: res.body,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
subscribe("/user/queue/greeting", (res) => {
|
||||||
stompClient.subscribe("/user/queue/greeting", (res: any) => {
|
|
||||||
const messageData = JSON.parse(res.body) as MessageType;
|
const messageData = JSON.parse(res.body) as MessageType;
|
||||||
messages.value.push({
|
messages.value.push({
|
||||||
sender: messageData.sender,
|
sender: messageData.sender,
|
||||||
content: messageData.content,
|
content: messageData.content,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
messages.value.push({
|
||||||
onStompError: (frame: any) => {
|
sender: "Server",
|
||||||
console.error("Broker reported error: " + frame.headers["message"]);
|
content: "Websocket 已连接",
|
||||||
console.error("Additional details: " + frame.body);
|
type: "tip",
|
||||||
},
|
});
|
||||||
onDisconnect: () => {
|
} else {
|
||||||
isConnected.value = false;
|
|
||||||
messages.value.push({
|
messages.value.push({
|
||||||
sender: "Server",
|
sender: "Server",
|
||||||
content: "Websocket 已断开",
|
content: "Websocket 已断开",
|
||||||
type: "tip",
|
type: "tip",
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
});
|
|
||||||
|
|
||||||
stompClient.activate();
|
|
||||||
}
|
|
||||||
|
|
||||||
function disconnectWebSocket() {
|
|
||||||
if (stompClient && stompClient.connected) {
|
|
||||||
stompClient.deactivate();
|
|
||||||
isConnected.value = false;
|
|
||||||
messages.value.push({
|
|
||||||
sender: "Server",
|
|
||||||
content: "Websocket 已断开",
|
|
||||||
type: "tip",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 连接 WebSocket
|
||||||
|
function connectWebSocket() {
|
||||||
|
connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 断开 WebSocket
|
||||||
|
function disconnectWebSocket() {
|
||||||
|
disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送广播消息
|
||||||
function sendToAll() {
|
function sendToAll() {
|
||||||
if (stompClient.connected) {
|
if (client.value && client.value.connected) {
|
||||||
stompClient.publish({
|
client.value.publish({
|
||||||
destination: "/topic/notice",
|
destination: "/topic/notice",
|
||||||
body: topicMessage.value,
|
body: topicMessage.value,
|
||||||
});
|
});
|
||||||
@@ -196,9 +178,10 @@ function sendToAll() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 发送点对点消息
|
||||||
function sendToUser() {
|
function sendToUser() {
|
||||||
if (stompClient.connected) {
|
if (client.value && client.value.connected) {
|
||||||
stompClient.publish({
|
client.value.publish({
|
||||||
destination: "/app/sendToUser/" + receiver.value,
|
destination: "/app/sendToUser/" + receiver.value,
|
||||||
body: queneMessage.value,
|
body: queneMessage.value,
|
||||||
});
|
});
|
||||||
@@ -212,54 +195,52 @@ function sendToUser() {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
connectWebSocket();
|
connectWebSocket();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
disconnectWebSocket();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped lang="scss">
|
||||||
.message-container {
|
.chat-messages-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
.chat-message {
|
||||||
.message {
|
max-width: 80%;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
margin: 10px;
|
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
|
&--sent {
|
||||||
|
align-self: flex-end;
|
||||||
|
background-color: #dcf8c6;
|
||||||
|
}
|
||||||
|
&--received {
|
||||||
|
align-self: flex-start;
|
||||||
|
background-color: #e8e8e8;
|
||||||
|
}
|
||||||
|
&__content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
color: var(--el-text-color-primary); // 使用主题文本颜色
|
||||||
|
}
|
||||||
|
&__sender {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
&__receiver {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.system-notice {
|
||||||
.message--sent {
|
|
||||||
align-self: flex-end;
|
|
||||||
background-color: #dcf8c6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message--received {
|
|
||||||
align-self: flex-start;
|
|
||||||
background-color: #e8e8e8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-sender {
|
|
||||||
margin-bottom: 5px;
|
|
||||||
font-weight: bold;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-receiver {
|
|
||||||
margin-bottom: 5px;
|
|
||||||
font-weight: bold;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tip-message {
|
|
||||||
align-self: center;
|
align-self: center;
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
margin-bottom: 5px;
|
font-size: 0.9em;
|
||||||
font-style: italic;
|
color: var(--el-text-color-secondary);
|
||||||
text-align: center;
|
background-color: var(--el-fill-color-lighter);
|
||||||
background-color: #f0f0f0;
|
border-radius: 15px;
|
||||||
border-radius: 5px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -89,8 +89,8 @@ function back() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__return-home {
|
&__return-home {
|
||||||
display: block;
|
|
||||||
float: left;
|
float: left;
|
||||||
|
display: block;
|
||||||
width: 110px;
|
width: 110px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|||||||
@@ -86,7 +86,8 @@
|
|||||||
<!-- 验证码 -->
|
<!-- 验证码 -->
|
||||||
<el-form-item prop="captchaCode">
|
<el-form-item prop="captchaCode">
|
||||||
<div class="input-wrapper">
|
<div class="input-wrapper">
|
||||||
<svg-icon icon-class="captcha" class="mx-2" />
|
<div class="i-svg:captcha mx-2" />
|
||||||
|
|
||||||
<el-input
|
<el-input
|
||||||
v-model="loginFormData.captchaCode"
|
v-model="loginFormData.captchaCode"
|
||||||
auto-complete="off"
|
auto-complete="off"
|
||||||
@@ -126,10 +127,10 @@
|
|||||||
<el-text size="small">{{ $t("login.otherLoginMethods") }}</el-text>
|
<el-text size="small">{{ $t("login.otherLoginMethods") }}</el-text>
|
||||||
</el-divider>
|
</el-divider>
|
||||||
<div class="third-party-login">
|
<div class="third-party-login">
|
||||||
<svg-icon icon-class="wechat" class="icon" />
|
<div class="i-svg:wechat" />
|
||||||
<svg-icon icon-class="qq" class="icon" />
|
<div class="i-svg:qq" />
|
||||||
<svg-icon icon-class="github" class="icon" />
|
<div class="i-svg:github" />
|
||||||
<svg-icon icon-class="gitee" class="icon" />
|
<div class="i-svg:gitee" />
|
||||||
</div>
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -59,14 +59,14 @@
|
|||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item>
|
<el-descriptions-item>
|
||||||
<template #label>
|
<template #label>
|
||||||
<SvgIcon icon-class="tree" />
|
<div class="i-svg:tree" />
|
||||||
部门
|
部门
|
||||||
</template>
|
</template>
|
||||||
{{ userProfile.deptName }}
|
{{ userProfile.deptName }}
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item>
|
<el-descriptions-item>
|
||||||
<template #label>
|
<template #label>
|
||||||
<SvgIcon icon-class="role" />
|
<div class="i-svg:role" />
|
||||||
角色
|
角色
|
||||||
</template>
|
</template>
|
||||||
{{ userProfile.roleNames }}
|
{{ userProfile.roleNames }}
|
||||||
@@ -268,7 +268,7 @@ import { Camera } from "@element-plus/icons-vue";
|
|||||||
|
|
||||||
const userProfile = ref<UserProfileVO>({});
|
const userProfile = ref<UserProfileVO>({});
|
||||||
|
|
||||||
enum DialogType {
|
const enum DialogType {
|
||||||
ACCOUNT = "account",
|
ACCOUNT = "account",
|
||||||
PASSWORD = "password",
|
PASSWORD = "password",
|
||||||
MOBILE = "mobile",
|
MOBILE = "mobile",
|
||||||
@@ -287,10 +287,10 @@ const mobileUpdateForm = reactive<MobileUpdateForm>({});
|
|||||||
const emailUpdateForm = reactive<EmailUpdateForm>({});
|
const emailUpdateForm = reactive<EmailUpdateForm>({});
|
||||||
|
|
||||||
const mobileCountdown = ref(0);
|
const mobileCountdown = ref(0);
|
||||||
const mobileTimer = ref<NodeJS.Timeout | null>(null);
|
const mobileTimer = ref();
|
||||||
|
|
||||||
const emailCountdown = ref(0);
|
const emailCountdown = ref(0);
|
||||||
const emailTimer = ref<NodeJS.Timeout | null>(null);
|
const emailTimer = ref();
|
||||||
|
|
||||||
// 修改密码校验规则
|
// 修改密码校验规则
|
||||||
const passwordChangeRules = {
|
const passwordChangeRules = {
|
||||||
@@ -458,7 +458,7 @@ const handleFileChange = async (event: Event) => {
|
|||||||
if (file) {
|
if (file) {
|
||||||
// 调用文件上传API
|
// 调用文件上传API
|
||||||
try {
|
try {
|
||||||
const data = await FileAPI.upload(file);
|
const data = await FileAPI.uploadFile(file);
|
||||||
// 更新用户头像
|
// 更新用户头像
|
||||||
userProfile.value.avatar = data.url;
|
userProfile.value.avatar = data.url;
|
||||||
// 更新用户信息
|
// 更新用户信息
|
||||||
@@ -466,6 +466,7 @@ const handleFileChange = async (event: Event) => {
|
|||||||
avatar: data.url,
|
avatar: data.url,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("头像上传失败:" + error);
|
||||||
ElMessage.error("头像上传失败");
|
ElMessage.error("头像上传失败");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,8 +137,8 @@ defineOptions({
|
|||||||
|
|
||||||
import ConfigAPI, { ConfigPageVO, ConfigForm, ConfigPageQuery } from "@/api/system/config";
|
import ConfigAPI, { ConfigPageVO, ConfigForm, ConfigPageQuery } from "@/api/system/config";
|
||||||
|
|
||||||
const queryFormRef = ref(ElForm);
|
const queryFormRef = ref();
|
||||||
const dataFormRef = ref(ElForm);
|
const dataFormRef = ref();
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const selectIds = ref<number[]>([]);
|
const selectIds = ref<number[]>([]);
|
||||||
|
|||||||
@@ -160,8 +160,8 @@ defineOptions({
|
|||||||
|
|
||||||
import DeptAPI, { DeptVO, DeptForm, DeptQuery } from "@/api/system/dept";
|
import DeptAPI, { DeptVO, DeptForm, DeptQuery } from "@/api/system/dept";
|
||||||
|
|
||||||
const queryFormRef = ref(ElForm);
|
const queryFormRef = ref();
|
||||||
const deptFormRef = ref(ElForm);
|
const deptFormRef = ref();
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const selectIds = ref<number[]>([]);
|
const selectIds = ref<number[]>([]);
|
||||||
|
|||||||
@@ -144,8 +144,8 @@ const route = useRoute();
|
|||||||
|
|
||||||
const dictCode = ref(route.query.dictCode as string);
|
const dictCode = ref(route.query.dictCode as string);
|
||||||
|
|
||||||
const queryFormRef = ref(ElForm);
|
const queryFormRef = ref();
|
||||||
const dataFormRef = ref(ElForm);
|
const dataFormRef = ref();
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const ids = ref<number[]>([]);
|
const ids = ref<number[]>([]);
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
link
|
link
|
||||||
size="small"
|
size="small"
|
||||||
icon="edit"
|
icon="edit"
|
||||||
@click.stop="handleEditClick(scope.row.id, scope.row.name)"
|
@click.stop="handleEditClick(scope.row.id)"
|
||||||
>
|
>
|
||||||
编辑
|
编辑
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -133,8 +133,8 @@ import DictAPI, { DictPageQuery, DictPageVO, DictForm } from "@/api/system/dict"
|
|||||||
|
|
||||||
import router from "@/router";
|
import router from "@/router";
|
||||||
|
|
||||||
const queryFormRef = ref(ElForm);
|
const queryFormRef = ref();
|
||||||
const dataFormRef = ref(ElForm);
|
const dataFormRef = ref();
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const ids = ref<number[]>([]);
|
const ids = ref<number[]>([]);
|
||||||
@@ -198,7 +198,7 @@ function handleAddClick() {
|
|||||||
*
|
*
|
||||||
* @param id 字典ID
|
* @param id 字典ID
|
||||||
*/
|
*/
|
||||||
function handleEditClick(id: number, name: string) {
|
function handleEditClick(id: number) {
|
||||||
dialog.visible = true;
|
dialog.visible = true;
|
||||||
dialog.title = "修改字典";
|
dialog.title = "修改字典";
|
||||||
DictAPI.getFormData(id).then((data) => {
|
DictAPI.getFormData(id).then((data) => {
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ defineOptions({
|
|||||||
|
|
||||||
import LogAPI, { LogPageVO, LogPageQuery } from "@/api/system/log";
|
import LogAPI, { LogPageVO, LogPageQuery } from "@/api/system/log";
|
||||||
|
|
||||||
const queryFormRef = ref(ElForm);
|
const queryFormRef = ref();
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
</el-icon>
|
</el-icon>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="scope.row.icon">
|
<template v-else-if="scope.row.icon">
|
||||||
<svg-icon :icon-class="scope.row.icon" />
|
<div :class="`i-svg:${scope.row.icon}`" />
|
||||||
</template>
|
</template>
|
||||||
{{ scope.row.name }}
|
{{ scope.row.name }}
|
||||||
</template>
|
</template>
|
||||||
@@ -338,8 +338,8 @@ defineOptions({
|
|||||||
import MenuAPI, { MenuQuery, MenuForm, MenuVO } from "@/api/system/menu";
|
import MenuAPI, { MenuQuery, MenuForm, MenuVO } from "@/api/system/menu";
|
||||||
import { MenuTypeEnum } from "@/enums/MenuTypeEnum";
|
import { MenuTypeEnum } from "@/enums/MenuTypeEnum";
|
||||||
|
|
||||||
const queryFormRef = ref(ElForm);
|
const queryFormRef = ref();
|
||||||
const menuFormRef = ref(ElForm);
|
const menuFormRef = ref();
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const dialog = reactive({
|
const dialog = reactive({
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ defineOptions({
|
|||||||
|
|
||||||
import NoticeAPI, { NoticePageVO, NoticePageQuery } from "@/api/system/notice";
|
import NoticeAPI, { NoticePageVO, NoticePageQuery } from "@/api/system/notice";
|
||||||
|
|
||||||
const queryFormRef = ref(ElForm);
|
const queryFormRef = ref();
|
||||||
const noticeDetailRef = ref();
|
const noticeDetailRef = ref();
|
||||||
|
|
||||||
const pageData = ref<NoticePageVO[]>([]);
|
const pageData = ref<NoticePageVO[]>([]);
|
||||||
|
|||||||
@@ -13,8 +13,7 @@
|
|||||||
<div class="dialog-toolbar">
|
<div class="dialog-toolbar">
|
||||||
<!-- 全屏/退出全屏按钮 -->
|
<!-- 全屏/退出全屏按钮 -->
|
||||||
<el-button circle @click="toggleFullscreen">
|
<el-button circle @click="toggleFullscreen">
|
||||||
<SvgIcon v-if="isFullscreen" icon-class="fullscreen-exit" />
|
<div :class="`i-svg:${isFullscreen ? 'fullscreen-exit' : 'fullscreen'}`" />
|
||||||
<SvgIcon v-else icon-class="fullscreen" />
|
|
||||||
</el-button>
|
</el-button>
|
||||||
<!-- 关闭按钮 -->
|
<!-- 关闭按钮 -->
|
||||||
<el-button circle @click="handleClose">
|
<el-button circle @click="handleClose">
|
||||||
|
|||||||
@@ -177,9 +177,7 @@
|
|||||||
<el-input v-model="formData.title" placeholder="通知标题" clearable />
|
<el-input v-model="formData.title" placeholder="通知标题" clearable />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="通知内容" prop="content">
|
<el-form-item label="通知内容" prop="content">
|
||||||
<div style="border: 1px solid #dcdfe6; border-radius: 4px">
|
<WangEditor v-model="formData.content" />
|
||||||
<WangEditor v-model="formData.content" style="min-height: 480px" />
|
|
||||||
</div>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="通知类型" prop="type">
|
<el-form-item label="通知类型" prop="type">
|
||||||
<Dict v-model="formData.type" code="notice_type" />
|
<Dict v-model="formData.type" code="notice_type" />
|
||||||
@@ -225,8 +223,8 @@ defineOptions({
|
|||||||
import NoticeAPI, { NoticePageVO, NoticeForm, NoticePageQuery } from "@/api/system/notice";
|
import NoticeAPI, { NoticePageVO, NoticeForm, NoticePageQuery } from "@/api/system/notice";
|
||||||
import UserAPI from "@/api/system/user";
|
import UserAPI from "@/api/system/user";
|
||||||
|
|
||||||
const queryFormRef = ref(ElForm);
|
const queryFormRef = ref();
|
||||||
const dataFormRef = ref(ElForm);
|
const dataFormRef = ref();
|
||||||
const noticeDetailRef = ref();
|
const noticeDetailRef = ref();
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -411,8 +409,3 @@ onMounted(() => {
|
|||||||
handleQuery();
|
handleQuery();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<style>
|
|
||||||
.editor-wrapper {
|
|
||||||
border: 1px solid #dcdfe6;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -211,9 +211,9 @@ defineOptions({
|
|||||||
import RoleAPI, { RolePageVO, RoleForm, RolePageQuery } from "@/api/system/role";
|
import RoleAPI, { RolePageVO, RoleForm, RolePageQuery } from "@/api/system/role";
|
||||||
import MenuAPI from "@/api/system/menu";
|
import MenuAPI from "@/api/system/menu";
|
||||||
|
|
||||||
const queryFormRef = ref(ElForm);
|
const queryFormRef = ref();
|
||||||
const roleFormRef = ref(ElForm);
|
const roleFormRef = ref();
|
||||||
const permTreeRef = ref<InstanceType<typeof ElTree>>();
|
const permTreeRef = ref();
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const ids = ref<number[]>([]);
|
const ids = ref<number[]>([]);
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const props = defineProps({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const deptList = ref<OptionType[]>(); // 部门列表
|
const deptList = ref<OptionType[]>(); // 部门列表
|
||||||
const deptTreeRef = ref(ElTree); // 部门树
|
const deptTreeRef = ref(); // 部门树
|
||||||
const deptName = ref(); // 部门名称
|
const deptName = ref(); // 部门名称
|
||||||
|
|
||||||
const emits = defineEmits(["node-click"]);
|
const emits = defineEmits(["node-click"]);
|
||||||
|
|||||||
@@ -175,8 +175,9 @@ const handleUpload = async () => {
|
|||||||
invalidCount.value = result.invalidCount;
|
invalidCount.value = result.invalidCount;
|
||||||
validCount.value = result.validCount;
|
validCount.value = result.validCount;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
ElMessage.error("上传失败");
|
console.error(error);
|
||||||
|
ElMessage.error("上传失败:" + error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,7 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="部门" width="120" align="center" prop="deptName" />
|
<el-table-column label="部门" width="120" align="center" prop="deptName" />
|
||||||
<el-table-column label="手机号码" align="center" prop="mobile" width="120" />
|
<el-table-column label="手机号码" align="center" prop="mobile" width="120" />
|
||||||
|
<el-table-column label="邮箱" align="center" prop="email" width="160" />
|
||||||
<el-table-column label="状态" align="center" prop="status" width="80">
|
<el-table-column label="状态" align="center" prop="status" width="80">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-tag :type="scope.row.status == 1 ? 'success' : 'info'">
|
<el-tag :type="scope.row.status == 1 ? 'success' : 'info'">
|
||||||
@@ -248,9 +249,8 @@ defineOptions({
|
|||||||
name: "User",
|
name: "User",
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
});
|
});
|
||||||
|
const queryFormRef = ref();
|
||||||
const queryFormRef = ref(ElForm);
|
const userFormRef = ref();
|
||||||
const userFormRef = ref(ElForm);
|
|
||||||
|
|
||||||
const queryParams = reactive<UserPageQuery>({
|
const queryParams = reactive<UserPageQuery>({
|
||||||
pageNum: 1,
|
pageNum: 1,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// uno.config.ts
|
// https://unocss.nodejs.cn/guide/config-file
|
||||||
import {
|
import {
|
||||||
defineConfig,
|
defineConfig,
|
||||||
presetAttributify,
|
presetAttributify,
|
||||||
@@ -10,6 +10,25 @@ import {
|
|||||||
transformerVariantGroup,
|
transformerVariantGroup,
|
||||||
} from "unocss";
|
} from "unocss";
|
||||||
|
|
||||||
|
import { FileSystemIconLoader } from "@iconify/utils/lib/loader/node-loaders";
|
||||||
|
import fs from "fs";
|
||||||
|
|
||||||
|
// 本地SVG图标目录
|
||||||
|
const iconsDir = "./src/assets/icons";
|
||||||
|
|
||||||
|
// 读取本地 SVG 目录,自动生成 safelist
|
||||||
|
const generateSafeList = () => {
|
||||||
|
try {
|
||||||
|
return fs
|
||||||
|
.readdirSync(iconsDir)
|
||||||
|
.filter((file) => file.endsWith(".svg"))
|
||||||
|
.map((file) => `i-svg:${file.replace(".svg", "")}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("无法读取图标目录:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
shortcuts: {
|
shortcuts: {
|
||||||
"flex-center": "flex justify-center items-center",
|
"flex-center": "flex justify-center items-center",
|
||||||
@@ -32,7 +51,22 @@ export default defineConfig({
|
|||||||
presets: [
|
presets: [
|
||||||
presetUno(),
|
presetUno(),
|
||||||
presetAttributify(),
|
presetAttributify(),
|
||||||
presetIcons(),
|
presetIcons({
|
||||||
|
// 额外属性
|
||||||
|
extraProperties: {
|
||||||
|
display: "inline-block",
|
||||||
|
width: "1em",
|
||||||
|
height: "1em",
|
||||||
|
},
|
||||||
|
// 图表集合
|
||||||
|
collections: {
|
||||||
|
// svg 是图标集合名称,使用 `i-svg:图标名` 调用
|
||||||
|
svg: FileSystemIconLoader(iconsDir, (svg) => {
|
||||||
|
// 如果 `fill` 没有定义,则添加 `fill="currentColor"`
|
||||||
|
return svg.includes('fill="') ? svg : svg.replace(/^<svg /, '<svg fill="currentColor" ');
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
presetTypography(),
|
presetTypography(),
|
||||||
presetWebFonts({
|
presetWebFonts({
|
||||||
fonts: {
|
fonts: {
|
||||||
@@ -40,5 +74,6 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
safelist: generateSafeList(),
|
||||||
transformers: [transformerDirectives(), transformerVariantGroup()],
|
transformers: [transformerDirectives(), transformerVariantGroup()],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import vue from "@vitejs/plugin-vue";
|
import vue from "@vitejs/plugin-vue";
|
||||||
import { type UserConfig, type ConfigEnv, loadEnv, defineConfig } from "vite";
|
import { type ConfigEnv, loadEnv, defineConfig } from "vite";
|
||||||
|
|
||||||
import AutoImport from "unplugin-auto-import/vite";
|
import AutoImport from "unplugin-auto-import/vite";
|
||||||
import Components from "unplugin-vue-components/vite";
|
import Components from "unplugin-vue-components/vite";
|
||||||
import { ElementPlusResolver } from "unplugin-vue-components/resolvers";
|
import { ElementPlusResolver } from "unplugin-vue-components/resolvers";
|
||||||
|
|
||||||
import { createSvgIconsPlugin } from "vite-plugin-svg-icons";
|
|
||||||
import mockDevServerPlugin from "vite-plugin-mock-dev-server";
|
import mockDevServerPlugin from "vite-plugin-mock-dev-server";
|
||||||
|
|
||||||
import UnoCSS from "unocss/vite";
|
import UnoCSS from "unocss/vite";
|
||||||
@@ -19,8 +18,9 @@ const __APP_INFO__ = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const pathSrc = resolve(__dirname, "src");
|
const pathSrc = resolve(__dirname, "src");
|
||||||
|
|
||||||
// Vite配置 https://cn.vitejs.dev/config
|
// Vite配置 https://cn.vitejs.dev/config
|
||||||
export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
|
export default defineConfig(({ mode }: ConfigEnv) => {
|
||||||
const env = loadEnv(mode, process.cwd());
|
const env = loadEnv(mode, process.cwd());
|
||||||
return {
|
return {
|
||||||
resolve: {
|
resolve: {
|
||||||
@@ -56,9 +56,7 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
|
|||||||
plugins: [
|
plugins: [
|
||||||
vue(),
|
vue(),
|
||||||
env.VITE_MOCK_DEV_SERVER === "true" ? mockDevServerPlugin() : null,
|
env.VITE_MOCK_DEV_SERVER === "true" ? mockDevServerPlugin() : null,
|
||||||
UnoCSS({
|
UnoCSS(),
|
||||||
hmrTopLevelAwait: false,
|
|
||||||
}),
|
|
||||||
// 自动导入配置 https://github.com/sxzz/element-plus-best-practices/blob/main/vite.config.ts
|
// 自动导入配置 https://github.com/sxzz/element-plus-best-practices/blob/main/vite.config.ts
|
||||||
AutoImport({
|
AutoImport({
|
||||||
// 导入 Vue 函数,如:ref, reactive, toRef 等
|
// 导入 Vue 函数,如:ref, reactive, toRef 等
|
||||||
@@ -88,11 +86,6 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
|
|||||||
dts: false,
|
dts: false,
|
||||||
// dts: "src/types/components.d.ts",
|
// dts: "src/types/components.d.ts",
|
||||||
}),
|
}),
|
||||||
createSvgIconsPlugin({
|
|
||||||
// 缓存图标位置
|
|
||||||
iconDirs: [resolve(pathSrc, "assets/icons")],
|
|
||||||
symbolId: "icon-[dir]-[name]",
|
|
||||||
}),
|
|
||||||
],
|
],
|
||||||
// 预加载项目必需的组件
|
// 预加载项目必需的组件
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
@@ -106,11 +99,17 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
|
|||||||
"sortablejs",
|
"sortablejs",
|
||||||
"exceljs",
|
"exceljs",
|
||||||
"path-to-regexp",
|
"path-to-regexp",
|
||||||
"echarts",
|
"echarts/core",
|
||||||
"@wangeditor/editor",
|
"echarts/renderers",
|
||||||
"@wangeditor/editor-for-vue",
|
"echarts/charts",
|
||||||
|
"echarts/components",
|
||||||
"vue-i18n",
|
"vue-i18n",
|
||||||
|
"nprogress",
|
||||||
|
"qs",
|
||||||
"path-browserify",
|
"path-browserify",
|
||||||
|
"@element-plus/icons-vue",
|
||||||
|
"element-plus/es/locale/lang/zh-cn",
|
||||||
|
"element-plus/es/locale/lang/en",
|
||||||
"element-plus/es/components/form/style/css",
|
"element-plus/es/components/form/style/css",
|
||||||
"element-plus/es/components/form-item/style/css",
|
"element-plus/es/components/form-item/style/css",
|
||||||
"element-plus/es/components/button/style/css",
|
"element-plus/es/components/button/style/css",
|
||||||
@@ -178,6 +177,7 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
|
|||||||
"element-plus/es/components/progress/style/css",
|
"element-plus/es/components/progress/style/css",
|
||||||
"element-plus/es/components/image-viewer/style/css",
|
"element-plus/es/components/image-viewer/style/css",
|
||||||
"element-plus/es/components/empty/style/css",
|
"element-plus/es/components/empty/style/css",
|
||||||
|
"element-plus/es/components/message/style/css",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// 构建配置
|
// 构建配置
|
||||||
|
|||||||
Reference in New Issue
Block a user