fix: 🐛 合并代码 解决冲突
This commit is contained in:
@@ -1,13 +1,15 @@
|
||||
module.exports = {
|
||||
// 继承推荐规范配置
|
||||
extends: [
|
||||
"stylelint-config-standard",
|
||||
"stylelint-config-recommended",
|
||||
"stylelint-config-recommended-scss",
|
||||
"stylelint-config-recommended-vue/scss",
|
||||
"stylelint-config-html/vue",
|
||||
"stylelint-config-recess-order",
|
||||
],
|
||||
// 指定不同文件对应的解析器
|
||||
|
||||
plugins: [
|
||||
"stylelint-prettier", // 统一代码风格,格式冲突时以 Prettier 规则为准
|
||||
],
|
||||
overrides: [
|
||||
{
|
||||
files: ["**/*.{vue,html}"],
|
||||
@@ -18,29 +20,18 @@ module.exports = {
|
||||
customSyntax: "postcss-scss",
|
||||
},
|
||||
],
|
||||
// 自定义规则
|
||||
rules: {
|
||||
"import-notation": "string", // 指定导入CSS文件的方式("string"|"url")
|
||||
"selector-class-pattern": null, // 选择器类名命名规则
|
||||
"custom-property-pattern": null, // 自定义属性命名规则
|
||||
"keyframes-name-pattern": null, // 动画帧节点样式命名规则
|
||||
"no-descending-specificity": null, // 允许无降序特异性
|
||||
"no-empty-source": null, // 允许空样式
|
||||
// 允许 global 、export 、deep伪类
|
||||
"prettier/prettier": true, // 强制执行 Prettier 格式化规则(需配合 .prettierrc 配置文件)
|
||||
"no-empty-source": null, // 允许空的样式文件
|
||||
"declaration-property-value-no-unknown": null, // 允许非常规数值格式 ,如 height: calc(100% - 50)
|
||||
// 允许使用未知伪类
|
||||
"selector-pseudo-class-no-unknown": [
|
||||
true,
|
||||
{
|
||||
ignorePseudoClasses: ["global", "export", "deep"],
|
||||
},
|
||||
],
|
||||
// 允许未知属性
|
||||
"property-no-unknown": [
|
||||
true,
|
||||
{
|
||||
ignoreProperties: [],
|
||||
},
|
||||
],
|
||||
// 允许未知规则
|
||||
// 允许使用未知伪元素
|
||||
"at-rule-no-unknown": [
|
||||
true,
|
||||
{
|
||||
|
||||
58
README.md
58
README.md
@@ -3,8 +3,8 @@
|
||||
<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/Vite-6.0.5-green.svg"/>
|
||||
<img src="https://img.shields.io/badge/Element Plus-2.9.1-blue.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.4-blue.svg"/>
|
||||
<img src="https://img.shields.io/badge/license-MIT-green.svg"/>
|
||||
<a href="https://gitee.com/youlaiorg" target="_blank">
|
||||
<img src="https://img.shields.io/badge/Author-有来开源组织-orange.svg"/>
|
||||
@@ -26,37 +26,32 @@
|
||||
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
|
||||
## 项目简介
|
||||
|
||||
[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 版本,无过渡封装 ,易上手。
|
||||
- **数据交互**: 支持 `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
|
||||
# 项目打包
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
# 上传文件至远程服务器
|
||||
将本地打包生成的 dist 目录下的所有文件拷贝至服务器的 /usr/share/nginx/html 目录。
|
||||
以下是 Nginx 的配置示例:
|
||||
|
||||
# nginx.cofig 配置
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
}
|
||||
|
||||
# 反向代理配置
|
||||
location /prod-api/ {
|
||||
# api.youlai.tech 替换后端API地址,注意保留后面的斜杠 /
|
||||
# 请将 api.youlai.tech 替换为您的后端 API 地址,并注意保留后面的斜杠 /
|
||||
proxy_pass http://api.youlai.tech/;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
更多详细信息,请参考这篇文章:[Nginx 安装和配置](https://blog.csdn.net/u013737132/article/details/145667694)。
|
||||
|
||||
## 本地Mock
|
||||
|
||||
项目同时支持在线和本地 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)
|
||||
|
||||
- [ESLint+Prettier+Stylelint+EditorConfig 约束和统一前端代码规范](https://blog.csdn.net/u013737132/article/details/130190788)
|
||||
- [Husky + Lint-staged + Commitlint + Commitizen + cz-git 配置 Git 提交规范](https://blog.csdn.net/u013737132/article/details/130191363)
|
||||
- [ESLint+Prettier+Stylelint+EditorConfig 约束和统一前端代码规范](https://youlai.blog.csdn.net/article/details/145608723)
|
||||
- [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,
|
||||
breaklineChar: "|",
|
||||
skipQuestions: [],
|
||||
issuePrefixes: [
|
||||
{ value: "closed", name: "closed: ISSUES has been processed" },
|
||||
],
|
||||
issuePrefixes: [{ value: "closed", name: "closed: ISSUES has been processed" }],
|
||||
customIssuePrefixAlign: "top",
|
||||
emptyIssuePrefixAlias: "skip",
|
||||
customIssuePrefixAlias: "custom",
|
||||
|
||||
116
eslint.config.js
116
eslint.config.js
@@ -1,115 +1,99 @@
|
||||
// https://eslint.nodejs.cn/docs/latest/use/configure/configuration-files
|
||||
|
||||
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 pluginVue from "eslint-plugin-vue";
|
||||
import pluginTypeScript from "@typescript-eslint/eslint-plugin";
|
||||
import parserVue from "vue-eslint-parser"; // Vue 解析器
|
||||
import parserTypeScript from "@typescript-eslint/parser"; // TypeScript 解析器
|
||||
|
||||
// Prettier 插件及配置
|
||||
import configPrettier from "eslint-config-prettier";
|
||||
import pluginPrettier from "eslint-plugin-prettier";
|
||||
import configPrettier from "eslint-config-prettier"; // 禁用与 Prettier 冲突的规则
|
||||
import pluginPrettier from "eslint-plugin-prettier"; // 运行 Prettier 规则
|
||||
|
||||
// 解析器
|
||||
import * as parserVue from "vue-eslint-parser";
|
||||
import * as parserTypeScript from "@typescript-eslint/parser";
|
||||
// 解析自动导入配置
|
||||
import fs from "fs";
|
||||
const autoImportConfig = JSON.parse(fs.readFileSync(".eslintrc-auto-import.json", "utf-8"));
|
||||
|
||||
// 定义 ESLint 配置
|
||||
/** @type {import('eslint').Linter.Config[]} */
|
||||
export default [
|
||||
// 通用 JavaScript 配置
|
||||
// 指定检查文件和忽略文件
|
||||
{
|
||||
files: ["**/*.{js,mjs,cjs,ts,vue}"],
|
||||
ignores: ["**/*.d.ts"],
|
||||
},
|
||||
// 全局配置
|
||||
{
|
||||
...js.configs.recommended,
|
||||
ignores: ["**/.*", "dist/*", "*.d.ts", "public/*", "src/assets/**"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser, // 浏览器变量 (window, document 等)
|
||||
...globals.node, // Node.js 变量 (process, require 等)
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
...autoImportConfig.globals,
|
||||
...{
|
||||
PageQuery: "readonly",
|
||||
PageResult: "readonly",
|
||||
OptionType: "readonly",
|
||||
ResponseData: "readonly",
|
||||
ExcelResult: "readonly",
|
||||
TagView: "readonly",
|
||||
AppSettings: "readonly",
|
||||
__APP_INFO__: "readonly",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
prettier: pluginPrettier,
|
||||
},
|
||||
plugins: { prettier: pluginPrettier },
|
||||
rules: {
|
||||
...configPrettier.rules,
|
||||
...pluginPrettier.configs.recommended.rules,
|
||||
"no-debug": "off", // 禁止 debugger
|
||||
"prettier/prettier": [
|
||||
...configPrettier.rules, // 关闭与 Prettier 冲突的规则
|
||||
...pluginPrettier.configs.recommended.rules, // 启用 Prettier 规则
|
||||
"prettier/prettier": "error", // 强制 Prettier 格式化
|
||||
"no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
endOfLine: "auto", // 自动识别换行符
|
||||
argsIgnorePattern: "^_", // 忽略参数名以 _ 开头的参数未使用警告
|
||||
varsIgnorePattern: "^[A-Z0-9_]+$", // 忽略变量名为大写字母、数字或下划线组合的未使用警告(枚举定义未使用场景)
|
||||
ignoreRestSiblings: true, // 忽略解构赋值中同级未使用变量的警告
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// JavaScript 配置
|
||||
pluginJs.configs.recommended,
|
||||
|
||||
// TypeScript 配置
|
||||
{
|
||||
files: ["**/*.?([cm])ts"],
|
||||
files: ["**/*.ts"],
|
||||
ignores: ["**/*.d.ts"], // 排除d.ts文件
|
||||
languageOptions: {
|
||||
parser: parserTypeScript,
|
||||
parserOptions: {
|
||||
sourceType: "module",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"@typescript-eslint": pluginTypeScript,
|
||||
},
|
||||
plugins: { "@typescript-eslint": pluginTypeScript },
|
||||
rules: {
|
||||
...pluginTypeScript.configs.strict.rules,
|
||||
...pluginTypeScript.configs.strict.rules, // TypeScript 严格规则
|
||||
"@typescript-eslint/no-explicit-any": "off", // 允许使用 any
|
||||
"@typescript-eslint/no-empty-function": "off", // 允许空函数
|
||||
"@typescript-eslint/no-empty-object-type": "off", // 允许空对象类型
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"error",
|
||||
{ disallowTypeAnnotations: false, fixStyle: "inline-type-imports" },
|
||||
], // 统一类型导入风格
|
||||
},
|
||||
},
|
||||
|
||||
// TypeScript 声明文件的特殊配置
|
||||
{
|
||||
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 文件配置
|
||||
// Vue 配置
|
||||
{
|
||||
files: ["**/*.vue"],
|
||||
languageOptions: {
|
||||
parser: parserVue,
|
||||
parserOptions: {
|
||||
parser: "@typescript-eslint/parser",
|
||||
parser: parserTypeScript,
|
||||
sourceType: "module",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
vue: pluginVue,
|
||||
},
|
||||
plugins: { vue: pluginVue, "@typescript-eslint": pluginTypeScript },
|
||||
processor: pluginVue.processors[".vue"],
|
||||
rules: {
|
||||
...pluginVue.configs["vue3-recommended"].rules,
|
||||
...pluginVue.configs["vue3-recommended"].rules, // Vue 3 推荐规则
|
||||
"vue/no-v-html": "off", // 允许 v-html
|
||||
"vue/require-default-prop": "off", // 允许没有默认值的 prop
|
||||
"vue/multi-word-component-names": "off", // 关闭组件名称多词要求
|
||||
"vue/html-self-closing": [
|
||||
"error",
|
||||
{
|
||||
html: { void: "always", normal: "always", component: "always" },
|
||||
svg: "always",
|
||||
math: "always",
|
||||
},
|
||||
], // 自闭合标签
|
||||
"vue/multi-word-component-names": "off", // 允许单个单词组件名
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
81
package.json
81
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vue3-element-admin",
|
||||
"version": "2.20.4",
|
||||
"version": "2.23.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -9,9 +9,9 @@
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"lint:eslint": "eslint --fix ./src",
|
||||
"lint:prettier": "prettier --write \"**/*.{js,cjs,ts,json,tsx,css,less,scss,vue,html,md}\"",
|
||||
"lint:stylelint": "stylelint \"**/*.{css,scss,vue}\" --fix",
|
||||
"lint:eslint": "eslint --cache \"src/**/*.{vue,ts}\" --fix",
|
||||
"lint:prettier": "prettier --write \"**/*.{js,cjs,ts,json,css,scss,vue,html,md}\"",
|
||||
"lint:stylelint": "stylelint --cache \"**/*.{css,scss,vue}\" --fix",
|
||||
"lint:lint-staged": "lint-staged",
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"prepare": "husky",
|
||||
@@ -46,70 +46,73 @@
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"@stomp/stompjs": "^7.0.0",
|
||||
"@vueuse/core": "^10.11.1",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "5.1.10",
|
||||
"@vueuse/core": "^12.6.1",
|
||||
"@wangeditor-next/editor": "^5.6.31",
|
||||
"@wangeditor-next/editor-for-vue": "^5.1.14",
|
||||
"animate.css": "^4.1.1",
|
||||
"axios": "^1.7.9",
|
||||
"codemirror": "^5.65.18",
|
||||
"codemirror-editor-vue3": "^2.8.0",
|
||||
"default-passive-events": "^2.0.0",
|
||||
"echarts": "^5.6.0",
|
||||
"element-plus": "^2.9.3",
|
||||
"element-plus": "^2.9.4",
|
||||
"exceljs": "^4.4.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"nprogress": "^0.2.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"path-to-regexp": "^6.3.0",
|
||||
"pinia": "^2.3.0",
|
||||
"path-to-regexp": "^8.2.0",
|
||||
"pinia": "^3.0.1",
|
||||
"qs": "^6.14.0",
|
||||
"sortablejs": "^1.15.6",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "9.9.1",
|
||||
"vue-i18n": "^11.1.1",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^19.6.1",
|
||||
"@commitlint/config-conventional": "^19.6.0",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@commitlint/cli": "^19.7.1",
|
||||
"@commitlint/config-conventional": "^19.7.1",
|
||||
"@eslint/js": "^9.20.0",
|
||||
"@iconify/utils": "^2.3.0",
|
||||
"@types/codemirror": "^5.60.15",
|
||||
"@types/lodash": "^4.17.14",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^22.13.4",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@types/path-browserify": "^1.0.3",
|
||||
"@types/qs": "^6.9.18",
|
||||
"@types/sortablejs": "^1.15.8",
|
||||
"@typescript-eslint/eslint-plugin": "^8.20.0",
|
||||
"@typescript-eslint/parser": "^8.20.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.24.0",
|
||||
"@typescript-eslint/parser": "^8.24.0",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"commitizen": "^4.3.1",
|
||||
"cz-git": "1.9.4",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"cz-git": "^1.11.0",
|
||||
"eslint": "^9.20.1",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.3",
|
||||
"eslint-plugin-vue": "^9.32.0",
|
||||
"globals": "^15.14.0",
|
||||
"globals": "^15.15.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^15.4.1",
|
||||
"postcss": "^8.5.1",
|
||||
"lint-staged": "^15.4.3",
|
||||
"postcss": "^8.5.2",
|
||||
"postcss-html": "^1.8.0",
|
||||
"postcss-scss": "^4.0.9",
|
||||
"prettier": "^3.4.2",
|
||||
"sass": "^1.83.4",
|
||||
"stylelint": "^16.13.2",
|
||||
"prettier": "^3.5.1",
|
||||
"sass": "^1.85.0",
|
||||
"stylelint": "^16.14.1",
|
||||
"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-vue": "^1.5.0",
|
||||
"stylelint-config-standard": "^36.0.1",
|
||||
"terser": "^5.37.0",
|
||||
"typescript": "5.5.4",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"unocss": "0.65.3",
|
||||
"unplugin-auto-import": "^0.18.6",
|
||||
"unplugin-vue-components": "^0.27.5",
|
||||
"vite": "^6.0.7",
|
||||
"vite-plugin-mock-dev-server": "^1.8.3",
|
||||
"vite-plugin-svg-icons": "^2.0.1",
|
||||
"stylelint-config-recommended-vue": "^1.6.0",
|
||||
"stylelint-prettier": "^5.0.3",
|
||||
"terser": "^5.39.0",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.24.0",
|
||||
"unocss": "65.4.3",
|
||||
"unplugin-auto-import": "^19.0.0",
|
||||
"unplugin-vue-components": "^28.0.0",
|
||||
"vite": "^6.1.0",
|
||||
"vite-plugin-mock-dev-server": "^1.8.4",
|
||||
"vue-eslint-parser": "^9.4.3",
|
||||
"vue-tsc": "^2.2.0"
|
||||
},
|
||||
|
||||
@@ -22,19 +22,17 @@ const AuthAPI = {
|
||||
|
||||
/** 刷新 token 接口*/
|
||||
refreshToken(refreshToken: string) {
|
||||
const formData = new FormData();
|
||||
formData.append("refreshToken", refreshToken);
|
||||
return request<any, LoginResult>({
|
||||
url: `${AUTH_BASE_URL}/refresh-token`,
|
||||
method: "post",
|
||||
data: formData,
|
||||
params: { refreshToken: refreshToken },
|
||||
headers: {
|
||||
Authorization: "no-auth",
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/** 注销接口 */
|
||||
/** 注销登录接口 */
|
||||
logout() {
|
||||
return request({
|
||||
url: `${AUTH_BASE_URL}/logout`,
|
||||
|
||||
@@ -2,16 +2,25 @@ import request from "@/utils/request";
|
||||
|
||||
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();
|
||||
formData.append("file", file);
|
||||
return request<any, FileInfo>({
|
||||
@@ -29,7 +38,7 @@ const FileAPI = {
|
||||
*
|
||||
* @param filePath 文件完整路径
|
||||
*/
|
||||
deleteByPath(filePath?: string) {
|
||||
delete(filePath?: string) {
|
||||
return request({
|
||||
url: "/api/v1/files",
|
||||
method: "delete",
|
||||
@@ -42,7 +51,7 @@ const FileAPI = {
|
||||
* @param url
|
||||
* @param fileName
|
||||
*/
|
||||
downloadFile(url: string, fileName?: string) {
|
||||
download(url: string, fileName?: string) {
|
||||
return request({
|
||||
url: url,
|
||||
method: "get",
|
||||
|
||||
@@ -240,7 +240,7 @@
|
||||
</el-icon>
|
||||
</template>
|
||||
<template v-else>
|
||||
<svg-icon :icon-class="scope.row[col.prop]" />
|
||||
<div class="i-svg:{{ scope.row[col.prop] }}" />
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
@@ -453,7 +453,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SvgIcon from "@/components/SvgIcon/index.vue";
|
||||
import { hasAuth } from "@/plugins/permission";
|
||||
import { useDateFormat, useThrottleFn } from "@vueuse/core";
|
||||
import {
|
||||
@@ -951,7 +950,7 @@ function exportPageData(formData: IObject = {}) {
|
||||
}
|
||||
|
||||
// 浏览器保存文件
|
||||
function saveXlsx(fileData: BlobPart, fileName: string) {
|
||||
function saveXlsx(fileData: any, fileName: string) {
|
||||
const fileType =
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8";
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
<slot
|
||||
:name="item.slotName ?? item.prop"
|
||||
:prop="item.prop"
|
||||
:formData="formData"
|
||||
:form-data="formData"
|
||||
:attrs="item.attrs"
|
||||
/>
|
||||
</template>
|
||||
@@ -137,11 +137,11 @@ function getFormData(key?: string) {
|
||||
// 设置表单值
|
||||
function setFormData(data: IObject) {
|
||||
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];
|
||||
}
|
||||
}
|
||||
if (data?.hasOwnProperty(props.pk)) {
|
||||
if (Object.prototype.hasOwnProperty.call(data, props.pk)) {
|
||||
formData[props.pk] = data[props.pk];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
<slot
|
||||
:name="item.slotName ?? item.prop"
|
||||
:prop="item.prop"
|
||||
:formData="formData"
|
||||
:form-data="formData"
|
||||
:attrs="item.attrs"
|
||||
/>
|
||||
</template>
|
||||
@@ -208,7 +208,7 @@
|
||||
<slot
|
||||
:name="item.slotName ?? item.prop"
|
||||
:prop="item.prop"
|
||||
:formData="formData"
|
||||
:form-data="formData"
|
||||
:attrs="item.attrs"
|
||||
/>
|
||||
</template>
|
||||
@@ -294,11 +294,11 @@ function getFormData(key?: string) {
|
||||
// 设置表单值
|
||||
function setFormData(data: IObject) {
|
||||
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];
|
||||
}
|
||||
}
|
||||
if (data?.hasOwnProperty(pk)) {
|
||||
if (Object.prototype.hasOwnProperty.call(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>
|
||||
<div @click="toggle">
|
||||
<svg-icon :icon-class="isFullscreen ? 'fullscreen-exit' : 'fullscreen'" />
|
||||
<div :class="`i-svg:` + (isFullscreen ? 'fullscreen-exit' : 'fullscreen')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
<!-- 汉堡按钮组件:展开/收缩菜单 -->
|
||||
<template>
|
||||
<div
|
||||
class="px-[15px] flex items-center justify-center color-[var(--el-text-color-regular)]"
|
||||
@click="toggleClick"
|
||||
>
|
||||
<svg-icon icon-class="collapse" :class="{ hamburger: true, 'is-active': isActive }" />
|
||||
<div class="hamburger-wrapper" @click="toggleClick">
|
||||
<div :class="['i-svg:collapse', { hamburger: true, 'is-active': isActive }]" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -25,6 +21,12 @@ function toggleClick() {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.hamburger-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 15px;
|
||||
|
||||
.hamburger {
|
||||
vertical-align: middle;
|
||||
cursor: pointer;
|
||||
@@ -34,4 +36,10 @@ function toggleClick() {
|
||||
.hamburger.is-active {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
}
|
||||
.layout-mix {
|
||||
.hamburger-wrapper {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<component :is="selectedIcon.replace('el-icon-', '')" />
|
||||
</el-icon>
|
||||
<template v-else>
|
||||
<svg-icon :icon-class="selectedIcon" />
|
||||
<div :class="`i-svg:${selectedIcon}`" />
|
||||
</template>
|
||||
</template>
|
||||
<template #suffix>
|
||||
@@ -52,7 +52,7 @@
|
||||
@click="selectIcon(icon)"
|
||||
>
|
||||
<el-tooltip :content="icon" placement="bottom" effect="light">
|
||||
<svg-icon :icon-class="icon" />
|
||||
<div :class="`i-svg:${icon}`" />
|
||||
</el-tooltip>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<template>
|
||||
<el-dropdown trigger="click" @command="handleLanguageChange">
|
||||
<div>
|
||||
<svg-icon icon-class="language" :size="size" />
|
||||
</div>
|
||||
<div class="i-svg:language" />
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
@@ -19,7 +17,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useAppStore } from "@/store/modules/app";
|
||||
import { LanguageEnum } from "@/enums/LanguageEnum";
|
||||
|
||||
@@ -38,6 +35,11 @@ const langOptions = [
|
||||
const appStore = useAppStore();
|
||||
const { locale, t } = useI18n();
|
||||
|
||||
/**
|
||||
* 处理语言切换
|
||||
*
|
||||
* @param lang 语言(zh-cn、en)
|
||||
*/
|
||||
function handleLanguageChange(lang: string) {
|
||||
locale.value = lang;
|
||||
appStore.changeLanguage(lang);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div @click="openSearchModal">
|
||||
<svg-icon icon-class="search" />
|
||||
<div class="i-svg:search" />
|
||||
<el-dialog
|
||||
v-model="isModalVisible"
|
||||
width="30%"
|
||||
@@ -38,8 +38,8 @@
|
||||
<el-icon v-if="item.icon && item.icon.startsWith('el-icon')">
|
||||
<component :is="item.icon.replace('el-icon-', '')" />
|
||||
</el-icon>
|
||||
<svg-icon v-else-if="item.icon" :icon-class="item.icon" />
|
||||
<svg-icon v-else icon-class="menu" />
|
||||
<div v-else-if="item.icon" :class="`i-svg:${item.icon}`" />
|
||||
<div v-else class="i-svg:menu" />
|
||||
{{ item.title }}
|
||||
</li>
|
||||
</ul>
|
||||
@@ -48,14 +48,15 @@
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<svg-icon icon-class="enter" size="20px" />
|
||||
<div class="i-svg:enter w-5 h-5" />
|
||||
<span>选择</span>
|
||||
|
||||
<svg-icon icon-class="down" size="20px" class="ml-5" />
|
||||
<svg-icon icon-class="up" size="20px" class="ml-1" />
|
||||
<div class="i-svg:down w-5 h-5 ml-5" />
|
||||
<div class="i-svg:up w-5 h-5 ml-5" />
|
||||
<span>切换</span>
|
||||
|
||||
<svg-icon icon-class="esc" size="20px" class="ml-5" />
|
||||
<div class="i-svg:esc w-5 h-5ml-5" />
|
||||
|
||||
<span>退出</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
total: {
|
||||
required: true,
|
||||
type: Number as PropType<number>,
|
||||
@@ -53,13 +53,26 @@ const currentPage = defineModel("page", {
|
||||
required: true,
|
||||
default: 1,
|
||||
});
|
||||
|
||||
const pageSize = defineModel("limit", {
|
||||
type: Number,
|
||||
required: true,
|
||||
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) {
|
||||
currentPage.value = 1;
|
||||
emit("pagination", { page: currentPage.value, limit: val });
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<el-tooltip :content="$t('sizeSelect.tooltip')" effect="dark" placement="bottom">
|
||||
<el-dropdown trigger="click" @command="handleSizeChange">
|
||||
<div>
|
||||
<svg-icon icon-class="size" />
|
||||
<div class="i-svg:size" />
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<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>
|
||||
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";
|
||||
|
||||
// 对象类型
|
||||
@@ -157,7 +157,7 @@ export interface ISelectConfig<T = any> {
|
||||
// popover组件属性
|
||||
popover?: Partial<Omit<PopoverProps, "visible" | "v-model:visible">>;
|
||||
// 列表的网络请求函数(需返回promise)
|
||||
indexAction: (queryParams: T) => Promise<any>;
|
||||
indexAction: (_queryParams: T) => Promise<any>;
|
||||
// 主键名(跨页选择必填,默认为id)
|
||||
pk?: string;
|
||||
// 多选
|
||||
@@ -284,7 +284,7 @@ const selectedItems = ref<IObject[]>([]);
|
||||
const confirmText = computed(() => {
|
||||
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) {
|
||||
// 多选
|
||||
selectedItems.value = selection;
|
||||
|
||||
@@ -3,53 +3,41 @@
|
||||
<div>
|
||||
<el-upload
|
||||
v-model:file-list="fileList"
|
||||
:class="props.showUploadBtn ? 'show-upload-btn' : 'hide-upload-btn'"
|
||||
:style="props.style"
|
||||
multiple
|
||||
:headers="props.headers"
|
||||
:data="props.data"
|
||||
:name="props.name"
|
||||
:before-upload="handleBeforeUpload"
|
||||
:on-remove="handleRemove"
|
||||
:http-request="handleUpload"
|
||||
:on-progress="handleProgress"
|
||||
:on-success="handleSuccessFile"
|
||||
:on-success="handleSuccess"
|
||||
:on-error="handleError"
|
||||
:action="props.action"
|
||||
:accept="props.accept"
|
||||
:limit="props.limit"
|
||||
multiple
|
||||
>
|
||||
<el-button
|
||||
v-if="props.showUploadBtn"
|
||||
type="primary"
|
||||
:disabled="fileList.length >= props.limit"
|
||||
>
|
||||
<!-- 上传文件按钮 -->
|
||||
<el-button type="primary" :disabled="fileList.length >= props.limit">
|
||||
{{ props.uploadBtnText }}
|
||||
</el-button>
|
||||
<template v-if="props.showTip" #tip>
|
||||
<div class="el-upload__tip">
|
||||
{{ props.tip }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 文件列表 -->
|
||||
<template #file="{ file }">
|
||||
<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>
|
||||
<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>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
|
||||
<el-progress
|
||||
v-if="showUploadPercent"
|
||||
:style="{
|
||||
display: showUploadPercent ? 'inline-flex' : 'none',
|
||||
display: showProgress ? 'inline-flex' : 'none',
|
||||
width: '100%',
|
||||
}"
|
||||
:percentage="uploadPercent"
|
||||
:color="customColorMethod"
|
||||
:percentage="progressPercent"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -57,98 +45,13 @@
|
||||
import {
|
||||
UploadRawFile,
|
||||
UploadUserFile,
|
||||
UploadFile,
|
||||
UploadProgressEvent,
|
||||
UploadFiles,
|
||||
UploadRequestOptions,
|
||||
} from "element-plus";
|
||||
|
||||
import FileAPI from "@/api/file";
|
||||
import { getToken } from "@/utils/auth";
|
||||
import { ResultEnum } from "@/enums/ResultEnum";
|
||||
import FileAPI, { FileInfo } from "@/api/file";
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
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,
|
||||
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 fileList = ref([] as UploadUserFile[]);
|
||||
const valFileList = ref([] as UploadUserFile[]);
|
||||
const showUploadPercent = ref(false);
|
||||
const uploadPercent = ref(0);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal: UploadUserFile[]) => {
|
||||
const filePaths = fileList.value.map((file) => file.url);
|
||||
const fileNames = fileList.value.map((file) => file.name);
|
||||
// 监听modelValue文件集合值未变化时,跳过赋值
|
||||
if (
|
||||
filePaths.length > 0 &&
|
||||
filePaths.length === newVal.length &&
|
||||
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;
|
||||
const modelValue = defineModel("modelValue", {
|
||||
type: [Array] as PropType<string[]>,
|
||||
required: true,
|
||||
default: () => [],
|
||||
});
|
||||
|
||||
valFileList.value = newVal.map((file) => {
|
||||
return { name: file.name, url: file.url } as UploadFile;
|
||||
const fileList = ref([] as UploadUserFile[]);
|
||||
|
||||
const showProgress = ref(false);
|
||||
const progressPercent = ref(0);
|
||||
|
||||
// 监听 modelValue 转换用于显示的 fileList
|
||||
watch(
|
||||
modelValue,
|
||||
(value) => {
|
||||
fileList.value = value.map((url) => {
|
||||
const name = url.substring(url.lastIndexOf("/") + 1);
|
||||
return {
|
||||
name: name,
|
||||
url: url,
|
||||
} as UploadUserFile;
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 限制用户上传文件的大小
|
||||
* 上传前校验
|
||||
*/
|
||||
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;
|
||||
}
|
||||
uploadPercent.value = 0;
|
||||
showUploadPercent.value = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
const handleSuccessFile = (response: any, file: UploadFile) => {
|
||||
showUploadPercent.value = false;
|
||||
uploadPercent.value = 0;
|
||||
if (response.code === ResultEnum.SUCCESS) {
|
||||
ElMessage.success("上传成功");
|
||||
valFileList.value.push({
|
||||
name: file.name,
|
||||
url: response.data.url,
|
||||
/*
|
||||
* 上传文件
|
||||
*/
|
||||
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]);
|
||||
});
|
||||
emit("update:modelValue", valFileList.value);
|
||||
return;
|
||||
} else {
|
||||
ElMessage.error(response.msg || "上传失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = (error: any) => {
|
||||
showUploadPercent.value = false;
|
||||
uploadPercent.value = 0;
|
||||
ElMessage.error("上传失败");
|
||||
};
|
||||
|
||||
const customColorMethod = (percentage: number) => {
|
||||
if (percentage < 30) {
|
||||
return "#909399";
|
||||
FileAPI.upload(formData)
|
||||
.then((data) => {
|
||||
resolve(data);
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
if (percentage < 70) {
|
||||
return "#375ee8";
|
||||
}
|
||||
return "#67c23a";
|
||||
};
|
||||
|
||||
/**
|
||||
* 上传进度
|
||||
*
|
||||
* @param event
|
||||
*/
|
||||
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) {
|
||||
const filePath = removeFile.url;
|
||||
if (filePath) {
|
||||
FileAPI.deleteByPath(filePath).then(() => {
|
||||
// 删除成功回调
|
||||
valFileList.value = valFileList.value.filter((file) => file.url !== filePath);
|
||||
emit("update:modelValue", valFileList.value);
|
||||
function handleRemove(fileUrl: string) {
|
||||
FileAPI.delete(fileUrl).then(() => {
|
||||
modelValue.value = modelValue.value.filter((url) => url !== fileUrl);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
*/
|
||||
function downloadFile(file: UploadUserFile) {
|
||||
const filePath = file.url;
|
||||
if (filePath) {
|
||||
FileAPI.downloadFile(filePath, file.name);
|
||||
function handleDownload(file: UploadUserFile) {
|
||||
const { url, name } = file;
|
||||
if (url) {
|
||||
FileAPI.download(url, name);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -298,8 +227,8 @@ function downloadFile(file: UploadUserFile) {
|
||||
color: var(--el-text-color-regular);
|
||||
cursor: pointer;
|
||||
opacity: 0.75;
|
||||
transition: opacity var(--el-transition-duration);
|
||||
transform: translateY(-50%);
|
||||
transition: opacity var(--el-transition-duration);
|
||||
}
|
||||
|
||||
:deep(.el-upload-list) {
|
||||
@@ -309,16 +238,4 @@ function downloadFile(file: UploadUserFile) {
|
||||
:deep(.el-upload-list__item) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.show-upload-btn {
|
||||
:deep(.el-upload) {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
.hide-upload-btn {
|
||||
:deep(.el-upload) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</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>
|
||||
<div class="editor-wrapper">
|
||||
<div style="z-index: 999; border: 1px solid #ccc">
|
||||
<!-- 工具栏 -->
|
||||
<Toolbar
|
||||
id="toolbar-container"
|
||||
:editor="editorRef"
|
||||
mode="simple"
|
||||
:default-config="toolbarConfig"
|
||||
:mode="mode"
|
||||
style="border-bottom: 1px solid #ccc"
|
||||
/>
|
||||
<!-- 编辑器 -->
|
||||
<Editor
|
||||
id="editor-container"
|
||||
v-model="modelValue"
|
||||
:style="{ height: height, overflowY: 'hidden' }"
|
||||
:default-config="editorConfig"
|
||||
:mode="mode"
|
||||
style="height: 500px; overflow-y: hidden"
|
||||
@on-change="handleChange"
|
||||
mode="simple"
|
||||
@on-created="handleCreated"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String],
|
||||
default: "",
|
||||
},
|
||||
excludeKeys: {
|
||||
type: Array<string>,
|
||||
default: [],
|
||||
// 上传图片回调函数类型
|
||||
type InsertFnType = (_url: string, _alt: string, _href: string) => void;
|
||||
|
||||
defineProps({
|
||||
height: {
|
||||
type: String,
|
||||
default: "500px",
|
||||
},
|
||||
});
|
||||
// 双向绑定
|
||||
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: "请输入内容...",
|
||||
MENU_CONF: {
|
||||
uploadImage: {
|
||||
// 自定义图片上传
|
||||
async customUpload(file: any, insertFn: any) {
|
||||
FileAPI.upload(file).then((data) => {
|
||||
insertFn(data.url);
|
||||
customUpload(file: File, insertFn: InsertFnType) {
|
||||
// 上传图片
|
||||
FileAPI.uploadFile(file).then((res) => {
|
||||
// 插入图片
|
||||
insertFn(res.url, res.name, res.url);
|
||||
});
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
|
||||
// 记录 editor 实例,重要!
|
||||
const handleCreated = (editor: any) => {
|
||||
editorRef.value = editor; // 记录 editor 实例,重要!
|
||||
editorRef.value = editor;
|
||||
};
|
||||
|
||||
function handleChange(editor: any) {
|
||||
modelValue.value = editor.getHtml();
|
||||
}
|
||||
|
||||
// 组件销毁时,也及时销毁编辑器
|
||||
// 组件销毁时,也及时销毁编辑器,重要!
|
||||
onBeforeUnmount(() => {
|
||||
const editor = editorRef.value;
|
||||
if (editor == null) return;
|
||||
editor.destroy();
|
||||
});
|
||||
</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 }">
|
||||
<router-view>
|
||||
<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">
|
||||
<component :is="Component" :key="route.path" />
|
||||
</keep-alive>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div :class="['navbar__right', navbarRightClass]">
|
||||
<!-- 非手机设备(窄屏)才显示 -->
|
||||
<template v-if="!isMobile">
|
||||
<!-- 桌面端显示 -->
|
||||
<template v-if="isDesktop">
|
||||
<!-- 搜索 -->
|
||||
<MenuSearch />
|
||||
|
||||
@@ -19,28 +19,52 @@
|
||||
</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">
|
||||
<SvgIcon icon-class="setting" />
|
||||
<div class="i-svg:setting" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import defaultSettings from "@/settings";
|
||||
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 { SidebarLightThemeEnum, ThemeEnum } from "@/enums/ThemeEnum";
|
||||
|
||||
const appStore = useAppStore();
|
||||
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 右侧的样式类
|
||||
const navbarRightClass = computed(() => {
|
||||
@@ -50,6 +74,27 @@ const navbarRightClass = computed(() => {
|
||||
// 如果是暗黑主题,或者是浅色主题中的深蓝色侧边栏配色
|
||||
return isDarkTheme || isDarkBlueSidebar ? "navbar__right--darkBlue" : "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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -71,14 +116,23 @@ const navbarRightClass = computed(() => {
|
||||
background: rgb(0 0 0 / 10%);
|
||||
}
|
||||
}
|
||||
.user-profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 0 13px;
|
||||
|
||||
&__avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
:deep(.el-divider--horizontal) {
|
||||
margin: 10px 0;
|
||||
&__name {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.dark .navbar__right > *:hover {
|
||||
background: rgb(255 255 255 / 20%);
|
||||
}
|
||||
|
||||
.layout-top .navbar__right--darkBlue > *,
|
||||
@@ -90,4 +144,8 @@ const navbarRightClass = computed(() => {
|
||||
.layout-mix .navbar__right--white > * {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.dark .navbar__right > *:hover {
|
||||
color: #ccc;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-dropdown class="wh-full">
|
||||
<el-badge v-if="notices.length > 0" :offset="[-10, 15]" :value="notices.length" :max="99">
|
||||
<el-icon>
|
||||
<Bell />
|
||||
</el-icon>
|
||||
</el-badge>
|
||||
<el-badge v-else>
|
||||
<el-dropdown class="h-full items-center justify-center" trigger="click">
|
||||
<el-badge v-if="notices.length > 0" :offset="[0, 15]" :value="notices.length" :max="99">
|
||||
<el-icon>
|
||||
<Bell />
|
||||
</el-icon>
|
||||
</el-badge>
|
||||
|
||||
<div v-else>
|
||||
<el-icon>
|
||||
<Bell />
|
||||
</el-icon>
|
||||
</div>
|
||||
|
||||
<template #dropdown>
|
||||
<div class="p-2">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="通知" name="notice">
|
||||
<div class="p-5">
|
||||
<template v-if="notices.length > 0">
|
||||
<div v-for="(item, index) in notices" :key="index" class="w500px py-3">
|
||||
<div class="flex-y-center">
|
||||
@@ -24,7 +23,7 @@
|
||||
size="small"
|
||||
class="w200px cursor-pointer !ml-2 !flex-1"
|
||||
truncated
|
||||
@click="handleReadNotice(item.id)"
|
||||
@click="readNotice(item.id)"
|
||||
>
|
||||
{{ item.title }}
|
||||
</el-text>
|
||||
@@ -34,15 +33,9 @@
|
||||
</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">
|
||||
<el-link type="primary" :underline="false" @click="viewMoreNotice">
|
||||
<span class="text-xs">查看更多</span>
|
||||
<el-icon class="text-xs">
|
||||
<ArrowRight />
|
||||
@@ -57,106 +50,12 @@
|
||||
<span class="text-xs">全部已读</span>
|
||||
</el-link>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="消息" name="message">
|
||||
<template v-if="messages.length > 0">
|
||||
<div
|
||||
v-for="(item, index) in messages"
|
||||
:key="index"
|
||||
class="w400px flex-x-between p-1"
|
||||
>
|
||||
<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="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>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
@@ -167,24 +66,21 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import NoticeAPI, { NoticePageVO } from "@/api/system/notice";
|
||||
import WebSocketManager from "@/utils/websocket";
|
||||
import router from "@/router";
|
||||
|
||||
const activeTab = ref("notice");
|
||||
const notices = ref<NoticePageVO[]>([]);
|
||||
const messages = ref<any[]>([]);
|
||||
const tasks = ref<any[]>([]);
|
||||
const noticeDetailRef = ref();
|
||||
|
||||
// 获取未读消息列表并连接 WebSocket
|
||||
onMounted(() => {
|
||||
NoticeAPI.getMyNoticePage({ pageNum: 1, pageSize: 5, isRead: 0 }).then((data) => {
|
||||
notices.value = data.list;
|
||||
});
|
||||
import { useStomp } from "@/hooks/useStomp";
|
||||
const { subscribe, unsubscribe, isConnected } = useStomp();
|
||||
|
||||
WebSocketManager.subscribeToTopic("/user/queue/message", (message) => {
|
||||
console.log("收到消息:", message);
|
||||
const data = JSON.parse(message);
|
||||
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({
|
||||
@@ -202,10 +98,21 @@ onMounted(() => {
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 获取我的通知公告
|
||||
*/
|
||||
function featchMyNotice() {
|
||||
NoticeAPI.getMyNoticePage({ pageNum: 1, pageSize: 5, isRead: 0 }).then((data) => {
|
||||
notices.value = data.list;
|
||||
});
|
||||
}
|
||||
|
||||
// 阅读通知公告
|
||||
function handleReadNotice(id: string) {
|
||||
function readNotice(id: string) {
|
||||
noticeDetailRef.value.openNotice(id);
|
||||
const index = notices.value.findIndex((notice) => notice.id === id);
|
||||
if (index >= 0) {
|
||||
@@ -214,7 +121,7 @@ function handleReadNotice(id: string) {
|
||||
}
|
||||
|
||||
// 查看更多
|
||||
function handleViewMore() {
|
||||
function viewMoreNotice() {
|
||||
router.push({ path: "/myNotice" });
|
||||
}
|
||||
|
||||
@@ -224,6 +131,14 @@ function markAllAsRead() {
|
||||
notices.value = [];
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
featchMyNotice();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unsubscribe("/user/queue/message");
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -234,4 +149,7 @@ function markAllAsRead() {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
:deep(.el-dropdown) {
|
||||
color: currentColor;
|
||||
}
|
||||
</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>
|
||||
<div class="flex flex-wrap justify-around w-full h-12">
|
||||
<el-tooltip content="左侧模式" placement="bottom">
|
||||
<div
|
||||
class="layout-item left"
|
||||
:class="{ 'is-active': modelValue === LayoutEnum.LEFT }"
|
||||
@click="updateValue(LayoutEnum.LEFT)"
|
||||
<div class="layout-select">
|
||||
<el-tooltip
|
||||
v-for="item in layoutOptions"
|
||||
:key="item.value"
|
||||
:content="item.label"
|
||||
placement="bottom"
|
||||
>
|
||||
<div />
|
||||
<div />
|
||||
</div>
|
||||
</el-tooltip>
|
||||
|
||||
<el-tooltip content="顶部模式" placement="bottom">
|
||||
<div
|
||||
class="layout-item top"
|
||||
:class="{ 'is-active': modelValue === LayoutEnum.TOP }"
|
||||
@click="updateValue(LayoutEnum.TOP)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:class="['layout-item', item.className, { 'is-active': modelValue === item.value }]"
|
||||
@click="handleLayoutChange(item.value)"
|
||||
@keydown.enter.space="handleLayoutChange(item.value)"
|
||||
>
|
||||
<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 class="layout-item-part" />
|
||||
<div class="layout-item-part" />
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
@@ -38,71 +23,118 @@
|
||||
<script lang="ts" setup>
|
||||
import { LayoutEnum } from "@/enums/LayoutEnum";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: String,
|
||||
interface LayoutOption {
|
||||
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 updateValue(layout: string) {
|
||||
emit("update:modelValue", layout);
|
||||
function handleLayoutChange(layout: LayoutEnum) {
|
||||
modelValue.value = layout;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout-selector {
|
||||
<style scoped lang="scss">
|
||||
.layout-select {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-around;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
gap: 10px;
|
||||
justify-content: space-evenly;
|
||||
padding: 10px 0;
|
||||
--layout-primary: #1b2a47;
|
||||
--layout-background: #f0f2f5;
|
||||
--layout-shadow: 0 0 8px rgba(0, 0, 0, 0.1);
|
||||
--layout-hover: #e3f1f9;
|
||||
}
|
||||
|
||||
.layout-item {
|
||||
position: relative;
|
||||
width: 18%;
|
||||
height: 45px;
|
||||
overflow: hidden;
|
||||
height: 50px;
|
||||
cursor: pointer;
|
||||
background: #f0f2f5;
|
||||
border-radius: 4px;
|
||||
background: var(--layout-background);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--layout-shadow);
|
||||
|
||||
&.mix div:nth-child(1),
|
||||
&.top div:nth-child(1) {
|
||||
width: 100%;
|
||||
height: 30%;
|
||||
background: #1b2a47;
|
||||
box-shadow: 0 0 1px #888;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
&: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;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 30%;
|
||||
height: 70%;
|
||||
background: #1b2a47;
|
||||
box-shadow: 0 0 1px #888;
|
||||
background: var(--layout-primary);
|
||||
border-radius: 4px; /* 保持和父容器一致的圆角 */
|
||||
box-shadow: var(--layout-shadow);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
&.left div:nth-child(1) {
|
||||
&.left {
|
||||
.layout-item-part {
|
||||
&:first-child {
|
||||
width: 30%;
|
||||
height: 100%;
|
||||
background: #1b2a47;
|
||||
border-radius: 4px 0 0 4px; /* 左边部分圆角 */
|
||||
}
|
||||
|
||||
&.left div:nth-child(2) {
|
||||
position: absolute;
|
||||
&:last-child {
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 70%;
|
||||
height: 30%;
|
||||
background: #fff;
|
||||
box-shadow: 0 0 1px #888;
|
||||
border-radius: 0 4px 4px 0; /* 右边部分圆角 */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layout-item.is-active {
|
||||
&.top {
|
||||
.layout-item-part:first-child {
|
||||
width: 100%;
|
||||
height: 30%;
|
||||
border-radius: 4px 4px 0 0; /* 顶部部分圆角 */
|
||||
}
|
||||
}
|
||||
|
||||
&.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; /* 底部部分圆角 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.is-active {
|
||||
background-color: var(--layout-hover);
|
||||
border: 2px solid var(--el-color-primary);
|
||||
transform: scale(1.05); /* 轻微放大 */
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<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="/">
|
||||
<img :src="logo" class="w20px h20px" />
|
||||
<span v-if="!collapse" class="title">
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
>
|
||||
<!-- 菜单项 -->
|
||||
<SidebarMenuItem
|
||||
v-for="route in menuList"
|
||||
v-for="route in data"
|
||||
:key="route.path"
|
||||
:item="route"
|
||||
:base-path="resolveFullPath(route.path)"
|
||||
@@ -39,6 +39,7 @@
|
||||
<script lang="ts" setup>
|
||||
import path from "path-browserify";
|
||||
import type { MenuInstance } from "element-plus";
|
||||
import type { RouteRecordRaw } from "vue-router";
|
||||
|
||||
import { LayoutEnum } from "@/enums/LayoutEnum";
|
||||
import { SidebarLightThemeEnum } from "@/enums/ThemeEnum";
|
||||
@@ -48,8 +49,8 @@ import { isExternal } from "@/utils/index";
|
||||
import variables from "@/styles/variables.module.scss";
|
||||
|
||||
const props = defineProps({
|
||||
menuList: {
|
||||
type: Array<any>,
|
||||
data: {
|
||||
type: Array<RouteRecordRaw>,
|
||||
required: true,
|
||||
default: () => [],
|
||||
},
|
||||
|
||||
@@ -149,10 +149,10 @@ function resolvePath(routePath: string) {
|
||||
|
||||
& > span {
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
width: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,10 +178,10 @@ function resolvePath(routePath: string) {
|
||||
.el-sub-menu {
|
||||
& > .el-sub-menu__title > span {
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
width: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<template>
|
||||
<!-- 根据 icon 类型决定使用的不同类型的图标组件 -->
|
||||
<el-icon v-if="icon && icon.startsWith('el-icon')" class="sub-el-icon">
|
||||
<component :is="icon.replace('el-icon-', '')" />
|
||||
<!-- 菜单图标 -->
|
||||
<template v-if="icon">
|
||||
<el-icon v-if="isElIcon" class="el-icon">
|
||||
<component :is="iconComponent" />
|
||||
</el-icon>
|
||||
<svg-icon v-else-if="icon" :icon-class="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>
|
||||
</template>
|
||||
@@ -12,32 +16,38 @@
|
||||
<script setup lang="ts">
|
||||
import { translateRouteTitle } from "@/utils/i18n";
|
||||
|
||||
defineProps({
|
||||
icon: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
const props = defineProps<{
|
||||
icon?: string;
|
||||
title?: string;
|
||||
}>();
|
||||
|
||||
const isElIcon = computed(() => props.icon?.startsWith("el-icon"));
|
||||
const iconComponent = computed(() => props.icon?.replace("el-icon-", ""));
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-el-icon {
|
||||
.el-icon {
|
||||
width: 14px !important;
|
||||
margin-right: 0 !important;
|
||||
color: currentcolor;
|
||||
}
|
||||
|
||||
[class^="i-svg:"] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: currentcolor !important;
|
||||
}
|
||||
|
||||
.hideSidebar {
|
||||
.el-sub-menu,
|
||||
.el-menu-item {
|
||||
.svg-icon,
|
||||
.sub-el-icon {
|
||||
.el-icon {
|
||||
margin-left: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
[class^="i-svg:"] {
|
||||
margin-left: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<el-icon v-if="route.meta.icon.startsWith('el-icon')" class="sub-el-icon">
|
||||
<component :is="route.meta.icon.replace('el-icon-', '')" />
|
||||
</el-icon>
|
||||
<svg-icon v-else :icon-class="route.meta.icon" />
|
||||
<div v-else :class="`i-svg:${route.meta.icon}`" />
|
||||
</template>
|
||||
<span v-if="route.path === '/'">首页</span>
|
||||
<span v-else-if="route.meta && route.meta.title" class="ml-1">
|
||||
@@ -40,18 +40,12 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 导入模块:先外部库,再内部模块,最后导入样式和工具类
|
||||
*/
|
||||
import { LocationQueryRaw, RouteRecordRaw } from "vue-router";
|
||||
import { usePermissionStore, useAppStore, useSettingsStore } from "@/store";
|
||||
import { translateRouteTitle } from "@/utils/i18n";
|
||||
import variables from "@/styles/variables.module.scss";
|
||||
import { SidebarLightThemeEnum } from "@/enums/ThemeEnum";
|
||||
|
||||
/**
|
||||
* 定义状态:先定义 reactive、ref 或 computed 状态
|
||||
*/
|
||||
const router = useRouter();
|
||||
const appStore = useAppStore();
|
||||
const permissionStore = usePermissionStore();
|
||||
@@ -84,8 +78,8 @@ appStore.activeTopMenu(activeTopMenuPath);
|
||||
*/
|
||||
const handleMenuSelect = (routePath: string) => {
|
||||
appStore.activeTopMenu(routePath); // 设置激活的顶部菜单
|
||||
permissionStore.setMixLeftMenus(routePath); // 更新左侧菜单
|
||||
navigateToFirstLeftMenu(permissionStore.mixLeftMenus); // 跳转到左侧第一个菜单
|
||||
permissionStore.setMixedLayoutLeftRoutes(routePath); // 更新左侧菜单
|
||||
navigateToFirstLeftMenu(permissionStore.mixedLayoutLeftRoutes); // 跳转到左侧第一个菜单
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
<template>
|
||||
<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" />
|
||||
<SidebarMixTopMenu class="flex-1" />
|
||||
<NavbarRight />
|
||||
</div>
|
||||
|
||||
<!-- 顶部布局顶部 || 左侧布局左侧 -->
|
||||
<!-- 顶部布局 || 左侧布局 -->
|
||||
<template v-else>
|
||||
<SidebarLogo v-if="sidebarLogo" :collapse="isSidebarCollapsed" />
|
||||
<el-scrollbar>
|
||||
<SidebarMenu :menu-list="permissionStore.routes" base-path="" />
|
||||
<SidebarMenu :data="permissionStore.routes" base-path="" />
|
||||
</el-scrollbar>
|
||||
|
||||
<!-- 顶部布局导航 -->
|
||||
<NavbarRight v-if="isTopLayout" />
|
||||
<!-- 顶部导航 -->
|
||||
<NavbarRight v-if="layout == LayoutEnum.TOP" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -33,8 +33,6 @@ const permissionStore = usePermissionStore();
|
||||
const sidebarLogo = computed(() => settingsStore.sidebarLogo);
|
||||
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);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -28,27 +28,27 @@
|
||||
:style="{ left: left + 'px', top: top + 'px' }"
|
||||
>
|
||||
<li @click="refreshSelectedTag(selectedTag)">
|
||||
<svg-icon icon-class="refresh" />
|
||||
<div class="i-svg:refresh" />
|
||||
刷新
|
||||
</li>
|
||||
<li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">
|
||||
<svg-icon icon-class="close" />
|
||||
<div class="i-svg:close" />
|
||||
关闭
|
||||
</li>
|
||||
<li @click="closeOtherTags">
|
||||
<svg-icon icon-class="close_other" />
|
||||
<div class="i-svg:close_other" />
|
||||
关闭其它
|
||||
</li>
|
||||
<li v-if="!isFirstView()" @click="closeLeftTags">
|
||||
<svg-icon icon-class="close_left" />
|
||||
<div class="i-svg:close_left" />
|
||||
关闭左侧
|
||||
</li>
|
||||
<li v-if="!isLastView()" @click="closeRightTags">
|
||||
<svg-icon icon-class="close_right" />
|
||||
<div class="i-svg:close_right" />
|
||||
关闭右侧
|
||||
</li>
|
||||
<li @click="closeAllTags(selectedTag)">
|
||||
<svg-icon icon-class="close_all" />
|
||||
<div class="i-svg:close_all" />
|
||||
关闭所有
|
||||
</li>
|
||||
</ul>
|
||||
@@ -187,25 +187,17 @@ function isAffix(tag: TagView) {
|
||||
}
|
||||
|
||||
function isFirstView() {
|
||||
try {
|
||||
return (
|
||||
selectedTag.value.path === "/dashboard" ||
|
||||
selectedTag.value.fullPath === tagsViewStore.visitedViews[1].fullPath
|
||||
selectedTag.value.fullPath === tagsViewStore.visitedViews[1]?.fullPath
|
||||
);
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isLastView() {
|
||||
try {
|
||||
return (
|
||||
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) {
|
||||
|
||||
@@ -14,33 +14,33 @@
|
||||
<div v-if="layout === LayoutEnum.MIX" class="mix-container">
|
||||
<div class="mix-container-sidebar">
|
||||
<el-scrollbar>
|
||||
<SidebarMenu :menu-list="mixLeftMenus" :base-path="activeTopMenuPath" />
|
||||
<SidebarMenu :data="mixedLayoutLeftRoutes" :base-path="activeTopMenuPath" />
|
||||
</el-scrollbar>
|
||||
<div class="sidebar-toggle">
|
||||
<hamburger :is-active="appStore.sidebar.opened" @toggle-click="toggleSidebar" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="{ hasTagsView: showTagsView }" class="main-container">
|
||||
<TagsView v-if="showTagsView" />
|
||||
<div :class="{ hasTagsView: isShowTagsView }" class="main-container">
|
||||
<TagsView v-if="isShowTagsView" />
|
||||
<AppMain />
|
||||
<Settings v-if="defaultSettings.showSettings" />
|
||||
<!-- 返回顶部 -->
|
||||
<el-backtop target=".app-main">
|
||||
<svg-icon icon-class="backtop" size="24px" />
|
||||
<div class="i-svg:backtop w-6 h-6" />
|
||||
</el-backtop>
|
||||
</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" />
|
||||
<TagsView v-if="showTagsView" />
|
||||
<TagsView v-if="isShowTagsView" />
|
||||
<AppMain />
|
||||
<Settings v-if="defaultSettings.showSettings" />
|
||||
<!-- 返回顶部 -->
|
||||
<el-backtop target=".app-main">
|
||||
<svg-icon icon-class="backtop" size="24px" />
|
||||
<div class="i-svg:backtop w-6 h-6" />
|
||||
</el-backtop>
|
||||
</div>
|
||||
</div>
|
||||
@@ -62,15 +62,15 @@ const width = useWindowSize().width;
|
||||
const WIDTH_DESKTOP = 992; // 响应式布局容器固定宽度 大屏(>=1200px) 中屏(>=992px) 小屏(>=768px)
|
||||
const isMobile = computed(() => appStore.device === DeviceEnum.MOBILE);
|
||||
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 activeTopMenuPath = computed(() => appStore.activeTopMenuPath); // 顶部菜单激活path
|
||||
const mixLeftMenus = computed(() => permissionStore.mixLeftMenus); // 混合布局左侧菜单
|
||||
const mixedLayoutLeftRoutes = computed(() => permissionStore.mixedLayoutLeftRoutes); // 混合布局左侧菜单
|
||||
|
||||
watch(
|
||||
() => activeTopMenuPath.value,
|
||||
(newVal: string) => {
|
||||
permissionStore.setMixLeftMenus(newVal);
|
||||
permissionStore.setMixedLayoutLeftRoutes(newVal);
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
@@ -245,9 +245,11 @@ watch(route, () => {
|
||||
}
|
||||
|
||||
.hideSidebar {
|
||||
&.layout-left {
|
||||
.main-container {
|
||||
margin-left: $sidebar-width-collapsed;
|
||||
}
|
||||
}
|
||||
|
||||
&.layout-top {
|
||||
.main-container {
|
||||
@@ -280,8 +282,8 @@ watch(route, () => {
|
||||
&.mobile {
|
||||
.sidebar-container {
|
||||
pointer-events: none;
|
||||
transform: translate3d(-$sidebar-width, 0, 0);
|
||||
transition-duration: 0.3s;
|
||||
transform: translate3d(-210px, 0, 0);
|
||||
}
|
||||
|
||||
.main-container {
|
||||
@@ -291,13 +293,10 @@ watch(route, () => {
|
||||
}
|
||||
|
||||
.mobile {
|
||||
.main-container {
|
||||
.layout-mix,
|
||||
.layout-top,
|
||||
.layout-left {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
&.layout-top {
|
||||
// 顶部模式全局变量修改
|
||||
--el-menu-item-height: $navbar-height;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,8 +2,6 @@ import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import setupPlugins from "@/plugins";
|
||||
|
||||
// 本地SVG图标
|
||||
import "virtual:svg-icons-register";
|
||||
// 暗黑主题样式
|
||||
import "element-plus/theme-chalk/dark/css-vars.css";
|
||||
// 暗黑模式自定义变量
|
||||
@@ -11,6 +9,12 @@ import "@/styles/dark/css-vars.css";
|
||||
import "@/styles/index.scss";
|
||||
import "uno.css";
|
||||
|
||||
// 全局引入 animate.css
|
||||
import "animate.css";
|
||||
|
||||
// 自动为某些默认事件(如 touchstart、wheel 等)添加 { passive: true },提升滚动性能并消除控制台的非被动事件监听警告
|
||||
import "default-passive-events";
|
||||
|
||||
const app = createApp(App);
|
||||
// 注册插件
|
||||
app.use(setupPlugins);
|
||||
|
||||
@@ -6,7 +6,6 @@ import { setupRouter } from "@/router";
|
||||
import { setupStore } from "@/store";
|
||||
import { setupElIcons } from "./icons";
|
||||
import { setupPermission } from "./permission";
|
||||
import webSocketManager from "@/utils/websocket";
|
||||
import { InstallCodeMirror } from "codemirror-editor-vue3";
|
||||
|
||||
export default {
|
||||
@@ -23,8 +22,6 @@ export default {
|
||||
setupElIcons(app);
|
||||
// 路由守卫
|
||||
setupPermission();
|
||||
// 初始化 WebSocket
|
||||
webSocketManager.setupWebSocket();
|
||||
// 注册 CodeMirror
|
||||
app.use(InstallCodeMirror);
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NavigationGuardNext, RouteLocationNormalized, RouteRecordRaw } from "vue-router";
|
||||
import NProgress from "@/utils/nprogress";
|
||||
import { getToken } from "@/utils/auth";
|
||||
import { getAccessToken } from "@/utils/auth";
|
||||
import router from "@/router";
|
||||
import { usePermissionStore, useUserStore } from "@/store";
|
||||
|
||||
@@ -11,7 +11,7 @@ export function setupPermission() {
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
NProgress.start();
|
||||
|
||||
const isLogin = !!getToken(); // 判断是否登录
|
||||
const isLogin = !!getAccessToken(); // 判断是否登录
|
||||
if (isLogin) {
|
||||
if (to.path === "/login") {
|
||||
// 已登录,访问登录页,跳转到首页
|
||||
|
||||
@@ -8,22 +8,24 @@ const modules = import.meta.glob("../../views/**/**.vue");
|
||||
const Layout = () => import("@/layout/index.vue");
|
||||
|
||||
export const usePermissionStore = defineStore("permission", () => {
|
||||
// 所有路由,包括静态和动态路由
|
||||
// 储所有路由,包括静态路由和动态路由
|
||||
const routes = ref<RouteRecordRaw[]>([]);
|
||||
// 混合模式左侧菜单
|
||||
const mixLeftMenus = ref<RouteRecordRaw[]>([]);
|
||||
// 路由是否已加载
|
||||
// 混合模式左侧菜单路由
|
||||
const mixedLayoutLeftRoutes = ref<RouteRecordRaw[]>([]);
|
||||
// 路由是否加载完成
|
||||
const isRoutesLoaded = ref(false);
|
||||
|
||||
/**
|
||||
* 生成动态路由
|
||||
* 获取后台动态路由数据,解析并注册到全局路由
|
||||
*
|
||||
* @returns Promise<RouteRecordRaw[]> 解析后的动态路由列表
|
||||
*/
|
||||
function generateRoutes() {
|
||||
return new Promise<RouteRecordRaw[]>((resolve, reject) => {
|
||||
MenuAPI.getRoutes()
|
||||
.then((data) => {
|
||||
const dynamicRoutes = transformRoutes(data);
|
||||
routes.value = constantRoutes.concat(dynamicRoutes);
|
||||
const dynamicRoutes = parseDynamicRoutes(data);
|
||||
routes.value = [...constantRoutes, ...dynamicRoutes];
|
||||
isRoutesLoaded.value = true;
|
||||
resolve(dynamicRoutes);
|
||||
})
|
||||
@@ -34,14 +36,14 @@ export const usePermissionStore = defineStore("permission", () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 混合模式菜单下根据顶部菜单路径设置左侧菜单
|
||||
* 根据父菜单路径设置混合模式左侧菜单
|
||||
*
|
||||
* @param topMenuPath - 顶部菜单路径
|
||||
* @param parentPath 父菜单的路径,用于查找对应的菜单项
|
||||
*/
|
||||
const setMixLeftMenus = (topMenuPath: string) => {
|
||||
const matchedItem = routes.value.find((item) => item.path === topMenuPath);
|
||||
const setMixedLayoutLeftRoutes = (parentPath: string) => {
|
||||
const matchedItem = routes.value.find((item) => item.path === parentPath);
|
||||
if (matchedItem && matchedItem.children) {
|
||||
mixLeftMenus.value = matchedItem.children;
|
||||
mixedLayoutLeftRoutes.value = matchedItem.children;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -49,59 +51,63 @@ export const usePermissionStore = defineStore("permission", () => {
|
||||
* 重置路由
|
||||
*/
|
||||
const resetRouter = () => {
|
||||
// 删除动态路由,保留静态路由
|
||||
// 从 router 实例中移除动态路由
|
||||
routes.value.forEach((route) => {
|
||||
if (route.name && !constantRoutes.find((r) => r.name === route.name)) {
|
||||
// 从 router 实例中移除动态路由
|
||||
router.removeRoute(route.name);
|
||||
}
|
||||
});
|
||||
|
||||
// 清空本地存储的路由和菜单数据
|
||||
routes.value = [];
|
||||
mixLeftMenus.value = [];
|
||||
mixedLayoutLeftRoutes.value = [];
|
||||
isRoutesLoaded.value = false;
|
||||
};
|
||||
|
||||
return {
|
||||
routes,
|
||||
generateRoutes,
|
||||
mixLeftMenus,
|
||||
setMixLeftMenus,
|
||||
mixedLayoutLeftRoutes,
|
||||
isRoutesLoaded,
|
||||
generateRoutes,
|
||||
setMixedLayoutLeftRoutes,
|
||||
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 asyncRoutes: RouteRecordRaw[] = [];
|
||||
routes.forEach((route) => {
|
||||
const tmpRoute = { ...route } as RouteRecordRaw;
|
||||
// 顶级目录,替换为 Layout 组件
|
||||
if (tmpRoute.component?.toString() == "Layout") {
|
||||
tmpRoute.component = Layout;
|
||||
} else {
|
||||
// 其他菜单,根据组件路径动态加载组件
|
||||
const component = modules[`../../views/${tmpRoute.component}.vue`];
|
||||
if (component) {
|
||||
tmpRoute.component = component;
|
||||
} else {
|
||||
tmpRoute.component = modules["../../views/error-page/404.vue"];
|
||||
}
|
||||
const parseDynamicRoutes = (rawRoutes: RouteVO[]): RouteRecordRaw[] => {
|
||||
const parsedRoutes: RouteRecordRaw[] = [];
|
||||
|
||||
rawRoutes.forEach((route) => {
|
||||
const normalizedRoute = { ...route } as RouteRecordRaw;
|
||||
|
||||
// 处理组件路径
|
||||
normalizedRoute.component =
|
||||
normalizedRoute.component?.toString() === "Layout"
|
||||
? Layout
|
||||
: modules[`../../views/${normalizedRoute.component}.vue`] ||
|
||||
modules["../../views/error-page/404.vue"];
|
||||
|
||||
// 递归解析子路由
|
||||
if (normalizedRoute.children) {
|
||||
normalizedRoute.children = parseDynamicRoutes(route.children);
|
||||
}
|
||||
|
||||
if (tmpRoute.children) {
|
||||
tmpRoute.children = transformRoutes(route.children);
|
||||
}
|
||||
|
||||
asyncRoutes.push(tmpRoute);
|
||||
parsedRoutes.push(normalizedRoute);
|
||||
});
|
||||
|
||||
return asyncRoutes;
|
||||
return parsedRoutes;
|
||||
};
|
||||
|
||||
/**
|
||||
* 在组件外使用 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 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", () => {
|
||||
const userInfo = useStorage<UserInfo>("userInfo", {} as UserInfo);
|
||||
@@ -20,8 +20,8 @@ export const useUserStore = defineStore("user", () => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
AuthAPI.login(LoginFormData)
|
||||
.then((data) => {
|
||||
const { tokenType, accessToken, refreshToken } = data;
|
||||
setToken(tokenType + " " + accessToken); // Bearer eyJhbGciOiJIUzI1NiJ9.xxx.xxx
|
||||
const { accessToken, refreshToken } = data;
|
||||
setAccessToken(accessToken); // eyJhbGciOiJIUzI1NiJ9.xxx.xxx
|
||||
setRefreshToken(refreshToken);
|
||||
resolve();
|
||||
})
|
||||
@@ -77,8 +77,8 @@ export const useUserStore = defineStore("user", () => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
AuthAPI.refreshToken(refreshToken)
|
||||
.then((data) => {
|
||||
const { tokenType, accessToken, refreshToken } = data;
|
||||
setToken(tokenType + " " + accessToken);
|
||||
const { accessToken, refreshToken } = data;
|
||||
setAccessToken(accessToken);
|
||||
setRefreshToken(refreshToken);
|
||||
resolve();
|
||||
})
|
||||
|
||||
@@ -25,8 +25,9 @@ body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB",
|
||||
"Microsoft YaHei", "微软雅黑", Arial, sans-serif;
|
||||
font-family:
|
||||
"Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑",
|
||||
Arial, sans-serif;
|
||||
line-height: inherit;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-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"];
|
||||
Dict: (typeof import("./../components/Dict/index.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"];
|
||||
ElBreadcrumb: (typeof import("element-plus/es"))["ElBreadcrumb"];
|
||||
ElBreadcrumbItem: (typeof import("element-plus/es"))["ElBreadcrumbItem"];
|
||||
@@ -84,12 +85,11 @@ declare module "vue" {
|
||||
SidebarMenuItem: (typeof import("./../layout/components/Sidebar/components/SidebarMenuItem.vue"))["default"];
|
||||
SidebarMenuItemTitle: (typeof import("./../layout/components/Sidebar/components/SidebarMenuItemTitle.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"];
|
||||
SvgIcon: (typeof import("./../components/SvgIcon/index.vue"))["default"];
|
||||
TableSelect: (typeof import("./../components/TableSelect/index.vue"))["default"];
|
||||
TagsView: (typeof import("./../layout/components/TagsView/index.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"];
|
||||
}
|
||||
export interface ComponentCustomProperties {
|
||||
|
||||
@@ -3,11 +3,11 @@ const ACCESS_TOKEN_KEY = "access_token";
|
||||
// 刷新 token 缓存的 key
|
||||
const REFRESH_TOKEN_KEY = "refresh_token";
|
||||
|
||||
function getToken(): string {
|
||||
function getAccessToken(): string {
|
||||
return localStorage.getItem(ACCESS_TOKEN_KEY) || "";
|
||||
}
|
||||
|
||||
function setToken(token: string) {
|
||||
function setAccessToken(token: string) {
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
@@ -24,4 +24,4 @@ function clearToken() {
|
||||
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 { useUserStoreHook } from "@/store/modules/user";
|
||||
import { ResultEnum } from "@/enums/ResultEnum";
|
||||
import { getToken } from "@/utils/auth";
|
||||
import { getAccessToken } from "@/utils/auth";
|
||||
import router from "@/router";
|
||||
|
||||
// 创建 axios 实例
|
||||
@@ -16,10 +16,10 @@ const service = axios.create({
|
||||
// 请求拦截器
|
||||
service.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const accessToken = getToken();
|
||||
const accessToken = getAccessToken();
|
||||
// 如果 Authorization 设置为 no-auth,则不携带 Token,用于登录、刷新 Token 等接口
|
||||
if (config.headers.Authorization !== "no-auth" && accessToken) {
|
||||
config.headers.Authorization = accessToken;
|
||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||
} else {
|
||||
delete config.headers.Authorization;
|
||||
}
|
||||
@@ -44,7 +44,8 @@ service.interceptors.response.use(
|
||||
ElMessage.error(msg || "系统出错");
|
||||
return Promise.reject(new Error(msg || "Error"));
|
||||
},
|
||||
async (error: any) => {
|
||||
async (error) => {
|
||||
console.error("request error", error); // for debug
|
||||
// 非 2xx 状态码处理 401、403、500 等
|
||||
const { config, response } = error;
|
||||
if (response) {
|
||||
@@ -64,20 +65,21 @@ service.interceptors.response.use(
|
||||
|
||||
export default service;
|
||||
|
||||
// 刷新 Token 的锁
|
||||
// 是否正在刷新标识,避免重复刷新
|
||||
let isRefreshing = false;
|
||||
// 因 Token 过期导致失败的请求队列
|
||||
let requestsQueue: Array<() => void> = [];
|
||||
// 因 Token 过期导致的请求等待队列
|
||||
const waitingQueue: Array<() => void> = [];
|
||||
|
||||
// 刷新 Token 处理
|
||||
async function handleTokenRefresh(config: InternalAxiosRequestConfig) {
|
||||
return new Promise((resolve) => {
|
||||
const requestCallback = () => {
|
||||
config.headers.Authorization = getToken();
|
||||
// 封装需要重试的请求
|
||||
const retryRequest = () => {
|
||||
config.headers.Authorization = getAccessToken();
|
||||
resolve(service(config));
|
||||
};
|
||||
|
||||
requestsQueue.push(requestCallback);
|
||||
waitingQueue.push(retryRequest);
|
||||
|
||||
if (!isRefreshing) {
|
||||
isRefreshing = true;
|
||||
@@ -86,13 +88,13 @@ async function handleTokenRefresh(config: InternalAxiosRequestConfig) {
|
||||
useUserStoreHook()
|
||||
.refreshToken()
|
||||
.then(() => {
|
||||
// Token 刷新成功,执行请求队列
|
||||
requestsQueue.forEach((callback) => callback());
|
||||
requestsQueue = [];
|
||||
// 依次重试队列中所有请求, 重试后清空队列
|
||||
waitingQueue.forEach((callback) => callback());
|
||||
waitingQueue.length = 0;
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch((error: any) => {
|
||||
console.log("handleTokenRefresh error", error);
|
||||
// Token 刷新失败,清除用户数据并跳转到登录
|
||||
// 刷新 Token 失败,跳转登录页
|
||||
ElNotification({
|
||||
title: "提示",
|
||||
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"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<svg-icon :icon-class="getFileTreeNodeIcon(data.label)" />
|
||||
<div :class="`i-svg:${getFileTreeNodeIcon(data.label)}`" />
|
||||
<span class="ml-1">{{ data.label }}</span>
|
||||
</template>
|
||||
</el-tree>
|
||||
@@ -437,7 +437,7 @@ interface TreeNode {
|
||||
}
|
||||
const treeData = ref<TreeNode[]>([]);
|
||||
|
||||
const queryFormRef = ref(ElForm);
|
||||
const queryFormRef = ref();
|
||||
const queryParams = reactive<TablePageQuery>({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
@@ -535,7 +535,6 @@ const initSort = () => {
|
||||
ghostClass: "sortable-ghost", //拖拽样式
|
||||
handle: ".sortable-handle", //拖拽区域
|
||||
easing: "cubic-bezier(1, 0, 0, 1)",
|
||||
onStart: (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">
|
||||
<div class="flex-x-start">
|
||||
<img
|
||||
class="wh-80px rounded-full"
|
||||
class="w80px h80px rounded-full"
|
||||
:src="userStore.userInfo.avatar + '?imageView2/1/w/80/h/80'"
|
||||
/>
|
||||
<div class="ml-5">
|
||||
@@ -27,15 +27,15 @@
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<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-divider direction="vertical" />
|
||||
<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-divider direction="vertical" />
|
||||
<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>
|
||||
</div>
|
||||
</el-col>
|
||||
@@ -47,18 +47,18 @@
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<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-divider direction="vertical" />
|
||||
<el-link
|
||||
href="https://youlai.blog.csdn.net/article/details/130191394"
|
||||
target="_blank"
|
||||
>
|
||||
<SvgIcon icon-class="csdn" class="text-lg" />
|
||||
<div class="i-svg:csdn text-lg" />
|
||||
</el-link>
|
||||
<el-divider direction="vertical" />
|
||||
<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>
|
||||
</div>
|
||||
</el-col>
|
||||
@@ -70,7 +70,7 @@
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<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>
|
||||
</div>
|
||||
</el-col>
|
||||
@@ -116,7 +116,11 @@
|
||||
<div class="flex-y-center">
|
||||
<span class="text-lg">{{ visitStatsData.todayUvCount }}</span>
|
||||
<span
|
||||
:class="['text-xs', 'ml-2', getGrowthRateClass(visitStatsData.uvGrowthRate)]"
|
||||
:class="[
|
||||
'text-xs',
|
||||
'ml-2',
|
||||
computeGrowthRateClass(visitStatsData.uvGrowthRate),
|
||||
]"
|
||||
>
|
||||
<el-icon>
|
||||
<Top v-if="visitStatsData.uvGrowthRate > 0" />
|
||||
@@ -125,7 +129,7 @@
|
||||
{{ formatGrowthRate(visitStatsData.uvGrowthRate) }}
|
||||
</span>
|
||||
</div>
|
||||
<svg-icon icon-class="visitor" size="2em" />
|
||||
<div class="i-svg:visitor w-8 h-8" />
|
||||
</div>
|
||||
|
||||
<div class="flex-x-between mt-2 text-sm text-gray">
|
||||
@@ -172,7 +176,11 @@
|
||||
<div class="flex-y-center">
|
||||
<span class="text-lg">{{ visitStatsData.todayPvCount }}</span>
|
||||
<span
|
||||
:class="['text-xs', 'ml-2', getGrowthRateClass(visitStatsData.pvGrowthRate)]"
|
||||
:class="[
|
||||
'text-xs',
|
||||
'ml-2',
|
||||
computeGrowthRateClass(visitStatsData.pvGrowthRate),
|
||||
]"
|
||||
>
|
||||
<el-icon>
|
||||
<Top v-if="visitStatsData.pvGrowthRate > 0" />
|
||||
@@ -181,7 +189,7 @@
|
||||
{{ formatGrowthRate(visitStatsData.pvGrowthRate) }}
|
||||
</span>
|
||||
</div>
|
||||
<svg-icon icon-class="browser" size="2em" />
|
||||
<div class="i-svg:browser w-8 h-8" />
|
||||
</div>
|
||||
|
||||
<div class="flex-x-between mt-2 text-sm text-gray">
|
||||
@@ -197,7 +205,18 @@
|
||||
<el-row :gutter="10" class="mt-5">
|
||||
<!-- 访问趋势统计图 -->
|
||||
<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 :xs="24" :span="8">
|
||||
@@ -206,7 +225,7 @@
|
||||
<div class="flex-x-between">
|
||||
<div class="flex-y-center">通知公告</div>
|
||||
<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-link>
|
||||
</div>
|
||||
@@ -218,7 +237,7 @@
|
||||
<el-text truncated class="!mx-2 flex-1 !text-xs !text-gray">
|
||||
{{ item.title }}
|
||||
</el-text>
|
||||
<el-link @click="handleOpenNoticeDetail(item.id)">
|
||||
<el-link @click="openNoticeDetail(item.id)">
|
||||
<el-icon class="text-sm"><View /></el-icon>
|
||||
</el-link>
|
||||
</div>
|
||||
@@ -237,38 +256,43 @@ defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
import VisitTrend from "./components/visit-trend.vue";
|
||||
|
||||
import { dayjs } from "element-plus";
|
||||
import router from "@/router";
|
||||
|
||||
import LogAPI, { VisitStatsVO } from "@/api/system/log";
|
||||
import LogAPI, { VisitStatsVO, VisitTrendVO } from "@/api/system/log";
|
||||
import NoticeAPI, { NoticePageVO } from "@/api/system/notice";
|
||||
|
||||
import { useUserStore } from "@/store/modules/user";
|
||||
import { formatGrowthRate } from "@/utils";
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
const noticeDetailRef = ref();
|
||||
|
||||
// 当前通知公告列表
|
||||
const notices = ref<NoticePageVO[]>([]);
|
||||
|
||||
const userStore = useUserStore();
|
||||
const date: Date = new Date();
|
||||
// 当前时间(用于计算问候语)
|
||||
const currentDate = new Date();
|
||||
|
||||
// 问候语:根据当前小时返回不同问候语
|
||||
const greetings = computed(() => {
|
||||
const hours = date.getHours();
|
||||
const hours = currentDate.getHours();
|
||||
const nickname = userStore.userInfo.nickname;
|
||||
if (hours >= 6 && hours < 8) {
|
||||
return "晨起披衣出草堂,轩窗已自喜微凉🌅!";
|
||||
} else if (hours >= 8 && hours < 12) {
|
||||
return "上午好," + userStore.userInfo.nickname + "!";
|
||||
return `上午好,${nickname}!`;
|
||||
} else if (hours >= 12 && hours < 18) {
|
||||
return "下午好," + userStore.userInfo.nickname + "!";
|
||||
return `下午好,${nickname}!`;
|
||||
} else if (hours >= 18 && hours < 24) {
|
||||
return "晚上好," + userStore.userInfo.nickname + "!";
|
||||
return `晚上好,${nickname}!`;
|
||||
} else {
|
||||
return "偷偷向银河要了一把碎星,只等你闭上眼睛撒入你的梦中,晚安🌛!";
|
||||
}
|
||||
});
|
||||
|
||||
// 访客统计数据加载状态
|
||||
const visitStatsLoading = ref(true);
|
||||
// 访客统计数据
|
||||
const visitStatsData = ref<VisitStatsVO>({
|
||||
todayUvCount: 0,
|
||||
uvGrowthRate: 0,
|
||||
@@ -278,8 +302,15 @@ const visitStatsData = ref<VisitStatsVO>({
|
||||
totalPvCount: 0,
|
||||
});
|
||||
|
||||
// 加载访问统计数据
|
||||
const loadVisitStatsData = async () => {
|
||||
// 访问趋势日期范围(单位:天)
|
||||
const visitTrendDateRange = ref(7);
|
||||
// 访问趋势图表配置
|
||||
const visitTrendChartOptions = ref();
|
||||
|
||||
/**
|
||||
* 获取访客统计数据
|
||||
*/
|
||||
const fetchVisitStatsData = () => {
|
||||
LogAPI.getVisitStats()
|
||||
.then((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) {
|
||||
return "color-[--el-color-info]";
|
||||
}
|
||||
|
||||
if (growthRate > 0) {
|
||||
return "color-[--el-color-danger]";
|
||||
} 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) => {
|
||||
notices.value = data.list;
|
||||
});
|
||||
};
|
||||
|
||||
// 查看更多
|
||||
function handleViewMoreNotice() {
|
||||
/**
|
||||
* 跳转至通知公告详情页面(查看更多通知)
|
||||
*/
|
||||
function navigateToNoticePage() {
|
||||
router.push({ path: "/myNotice" });
|
||||
}
|
||||
|
||||
// 打开通知公告
|
||||
function handleOpenNoticeDetail(id: string) {
|
||||
/**
|
||||
* 打开指定通知详情
|
||||
*
|
||||
* @param id - 通知 ID
|
||||
*/
|
||||
function openNoticeDetail(id: string) {
|
||||
noticeDetailRef.value.openNotice(id);
|
||||
}
|
||||
|
||||
// 监听访问趋势日期范围的变化,重新获取趋势数据
|
||||
watch(
|
||||
() => visitTrendDateRange.value,
|
||||
(newVal) => {
|
||||
console.log("Visit trend date range changed:", newVal);
|
||||
fetchVisitTrendData();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 组件挂载后加载访客统计数据和通知公告数据
|
||||
onMounted(() => {
|
||||
loadVisitStatsData();
|
||||
loadMyNotice();
|
||||
fetchVisitStatsData();
|
||||
fetchMyNotices();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<copy-button :text="generateIconCode(item)">
|
||||
<el-tooltip effect="dark" :content="generateIconCode(item)" placement="top">
|
||||
<div class="icon-item">
|
||||
<svg-icon :icon-class="item" />
|
||||
<div :class="`i-svg:${item}`" />
|
||||
<span>{{ item }}</span>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
@@ -36,7 +36,6 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SvgIcon from "@/components/SvgIcon/index.vue";
|
||||
import * as ElementPlusIconsVue from "@element-plus/icons-vue";
|
||||
|
||||
defineOptions({
|
||||
@@ -87,7 +86,7 @@ const svg_icons: string[] = [
|
||||
const icons = ref(ElementPlusIconsVue);
|
||||
|
||||
function generateIconCode(symbol: any) {
|
||||
return `<svg-icon icon-class="${symbol}" />`;
|
||||
return `<div class="i-svg:${symbol}" />`;
|
||||
}
|
||||
|
||||
function generateElementIconCode(symbol: any) {
|
||||
|
||||
@@ -68,7 +68,7 @@ const handleToFile = async () => {
|
||||
const file = dataURLtoFile(canvas.value.toDataURL(), "签名.png");
|
||||
|
||||
if (!file) return;
|
||||
const data = await FileAPI.upload(file);
|
||||
const data = await FileAPI.uploadFile(file);
|
||||
handleClearSign();
|
||||
imgUrl.value = data.url;
|
||||
};
|
||||
|
||||
@@ -97,7 +97,14 @@ const selectConfig: ISelectConfig = {
|
||||
{ label: "编号", align: "center", prop: "id", width: 100 },
|
||||
{ label: "用户名", align: "center", prop: "username" },
|
||||
{ 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: "mobile", width: 120 },
|
||||
{
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<!-- 列表选择器示例 -->
|
||||
<script setup lang="ts">
|
||||
import selectConfig from "./config/select";
|
||||
|
||||
import { useDictStore } from "@/store";
|
||||
const dictStore = useDictStore();
|
||||
interface IUser {
|
||||
id: number;
|
||||
username: string;
|
||||
nickname: string;
|
||||
mobile: string;
|
||||
genderLabel: string;
|
||||
gender: string;
|
||||
avatar: string;
|
||||
email: string | null;
|
||||
status: number;
|
||||
@@ -20,8 +21,11 @@ function handleConfirm(data: IUser[]) {
|
||||
selectedUser.value = data[0];
|
||||
}
|
||||
const text = computed(() => {
|
||||
// 获取字典数据
|
||||
const dictData = dictStore.getDictionary("gender");
|
||||
const genderLabel = dictData.find((item: any) => item.value == selectedUser.value?.gender)?.label;
|
||||
return selectedUser.value
|
||||
? `${selectedUser.value.username} - ${selectedUser.value.genderLabel} - ${selectedUser.value.deptName}`
|
||||
? `${selectedUser.value.username} - ${genderLabel} - ${selectedUser.value.deptName}`
|
||||
: "";
|
||||
});
|
||||
</script>
|
||||
@@ -42,6 +46,9 @@ const text = computed(() => {
|
||||
{{ scope.row[scope.prop] == 1 ? "启用" : "禁用" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<template #gender="scope">
|
||||
<DictLabel v-model="scope.row.gender" code="gender" />
|
||||
</template>
|
||||
</table-select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -11,216 +11,30 @@
|
||||
</el-link>
|
||||
|
||||
<el-form>
|
||||
<el-form-item label="绑定值">
|
||||
{{ picUrl }}
|
||||
<el-form-item label="单图上传">
|
||||
<SingleImageUpload v-model="picUrl" />
|
||||
</el-form-item>
|
||||
<el-form-item label="图片上传">
|
||||
<ImageUpload v-model="picUrl" :maxSize="10" />
|
||||
</el-form-item>
|
||||
<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 label="多图上传">
|
||||
<MultiImageUpload v-model="picUrls" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="文件上传">
|
||||
<FileUpload v-model="fileUrls" />
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import MultiImageUpload from "@/components/Upload/MultiImageUpload.vue";
|
||||
|
||||
// 单图
|
||||
const picUrl = 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 picUrls = ref(["https://s2.loli.net/2023/05/24/yNsxFC8rLHMZQcK.jpg"]);
|
||||
|
||||
const fileUrls = ref([
|
||||
{
|
||||
name: "file one.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",
|
||||
},
|
||||
"https://s2.loli.net/2023/05/24/yNsxFC8rLHMZQcK.jpg",
|
||||
"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>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<!-- wangEditor富文本编辑器示例 -->
|
||||
<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>
|
||||
|
||||
<template>
|
||||
@@ -15,6 +15,10 @@ const value = ref("初始内容");
|
||||
>
|
||||
示例源码 请点击>>>>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<el-row>
|
||||
<el-col :span="16">
|
||||
<el-input v-model="socketEndpoint" class="w-220px" />
|
||||
<el-col :span="18">
|
||||
<el-input v-model="socketEndpoint" style="width: 200px" />
|
||||
<el-button
|
||||
type="primary"
|
||||
class="ml-5"
|
||||
@@ -26,10 +26,10 @@
|
||||
断开
|
||||
</el-button>
|
||||
</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-else class="ml-2" type="info">已断开</el-tag>
|
||||
<el-tag v-if="isConnected" type="success">已连接</el-tag>
|
||||
<el-tag v-else type="info">已断开</el-tag>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
@@ -62,28 +62,31 @@
|
||||
<!-- 消息接收显示部分 -->
|
||||
<el-col :span="12">
|
||||
<el-card>
|
||||
<div class="message-container">
|
||||
<div class="chat-messages-wrapper">
|
||||
<div
|
||||
v-for="(message, index) in messages"
|
||||
:key="index"
|
||||
:class="{
|
||||
'tip-message': message.type === 'tip',
|
||||
message: message.type !== 'tip',
|
||||
'message--sent': message.sender === userStore.userInfo.username,
|
||||
'message--received': message.sender !== userStore.userInfo.username,
|
||||
}"
|
||||
:class="[
|
||||
message.type === 'tip' ? 'system-notice' : 'chat-message',
|
||||
{
|
||||
'chat-message--sent': 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 class="chat-message__content">
|
||||
<div
|
||||
:class="{
|
||||
'message-sender': message.sender === userStore.userInfo.username,
|
||||
'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 }}
|
||||
</div>
|
||||
<div class="color-#333">{{ message.content }}</div>
|
||||
<div class="text-gray-600">{{ message.content }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else>{{ message.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,98 +97,77 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Client } from "@stomp/stompjs";
|
||||
|
||||
import { useStomp } from "@/hooks/useStomp";
|
||||
import { useUserStoreHook } from "@/store/modules/user";
|
||||
import { getToken } from "@/utils/auth";
|
||||
|
||||
const userStore = useUserStoreHook();
|
||||
const isConnected = ref(false);
|
||||
// 用于手动调整 WebSocket 地址
|
||||
const socketEndpoint = ref(import.meta.env.VITE_APP_WS_ENDPOINT);
|
||||
|
||||
const receiver = ref("root");
|
||||
|
||||
// 同步连接状态
|
||||
interface MessageType {
|
||||
type?: string;
|
||||
sender?: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
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。"); // 广播消息
|
||||
|
||||
const queneMessage = ref(
|
||||
"hi , " + receiver.value + " , 我是" + userStore.userInfo.username + " , 想和你交个朋友 ! "
|
||||
);
|
||||
|
||||
let stompClient: Client;
|
||||
|
||||
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",
|
||||
// 调用 useStomp hook,默认使用 socketEndpoint 和 token(此处用 getAccessToken())
|
||||
const { isConnected, connect, subscribe, disconnect, client } = useStomp({
|
||||
debug: true,
|
||||
});
|
||||
|
||||
stompClient.subscribe("/topic/notice", (res: any) => {
|
||||
watch(
|
||||
() => isConnected.value,
|
||||
(connected) => {
|
||||
if (connected) {
|
||||
// 连接成功后,订阅广播和点对点消息主题
|
||||
subscribe("/topic/notice", (res) => {
|
||||
messages.value.push({
|
||||
sender: "Server",
|
||||
content: res.body,
|
||||
});
|
||||
});
|
||||
|
||||
stompClient.subscribe("/user/queue/greeting", (res: any) => {
|
||||
subscribe("/user/queue/greeting", (res) => {
|
||||
const messageData = JSON.parse(res.body) as MessageType;
|
||||
messages.value.push({
|
||||
sender: messageData.sender,
|
||||
content: messageData.content,
|
||||
});
|
||||
});
|
||||
},
|
||||
onStompError: (frame: any) => {
|
||||
console.error("Broker reported error: " + frame.headers["message"]);
|
||||
console.error("Additional details: " + frame.body);
|
||||
},
|
||||
onDisconnect: () => {
|
||||
isConnected.value = false;
|
||||
messages.value.push({
|
||||
sender: "Server",
|
||||
content: "Websocket 已连接",
|
||||
type: "tip",
|
||||
});
|
||||
} else {
|
||||
messages.value.push({
|
||||
sender: "Server",
|
||||
content: "Websocket 已断开",
|
||||
type: "tip",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
stompClient.activate();
|
||||
// 连接 WebSocket
|
||||
function connectWebSocket() {
|
||||
connect();
|
||||
}
|
||||
|
||||
// 断开 WebSocket
|
||||
function disconnectWebSocket() {
|
||||
if (stompClient && stompClient.connected) {
|
||||
stompClient.deactivate();
|
||||
isConnected.value = false;
|
||||
messages.value.push({
|
||||
sender: "Server",
|
||||
content: "Websocket 已断开",
|
||||
type: "tip",
|
||||
});
|
||||
}
|
||||
disconnect();
|
||||
}
|
||||
|
||||
// 发送广播消息
|
||||
function sendToAll() {
|
||||
if (stompClient.connected) {
|
||||
stompClient.publish({
|
||||
if (client.value && client.value.connected) {
|
||||
client.value.publish({
|
||||
destination: "/topic/notice",
|
||||
body: topicMessage.value,
|
||||
});
|
||||
@@ -196,9 +178,10 @@ function sendToAll() {
|
||||
}
|
||||
}
|
||||
|
||||
// 发送点对点消息
|
||||
function sendToUser() {
|
||||
if (stompClient.connected) {
|
||||
stompClient.publish({
|
||||
if (client.value && client.value.connected) {
|
||||
client.value.publish({
|
||||
destination: "/app/sendToUser/" + receiver.value,
|
||||
body: queneMessage.value,
|
||||
});
|
||||
@@ -212,54 +195,52 @@ function sendToUser() {
|
||||
onMounted(() => {
|
||||
connectWebSocket();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disconnectWebSocket();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-container {
|
||||
<style scoped lang="scss">
|
||||
.chat-messages-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.message {
|
||||
.chat-message {
|
||||
max-width: 80%;
|
||||
padding: 10px;
|
||||
margin: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.message--sent {
|
||||
&--sent {
|
||||
align-self: flex-end;
|
||||
background-color: #dcf8c6;
|
||||
}
|
||||
|
||||
.message--received {
|
||||
&--received {
|
||||
align-self: flex-start;
|
||||
background-color: #e8e8e8;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--el-text-color-primary); // 使用主题文本颜色
|
||||
}
|
||||
|
||||
.message-sender {
|
||||
&__sender {
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.message-receiver {
|
||||
&__receiver {
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tip-message {
|
||||
}
|
||||
.system-notice {
|
||||
align-self: center;
|
||||
padding: 5px 10px;
|
||||
margin-bottom: 5px;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 5px;
|
||||
font-size: 0.9em;
|
||||
color: var(--el-text-color-secondary);
|
||||
background-color: var(--el-fill-color-lighter);
|
||||
border-radius: 15px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -89,8 +89,8 @@ function back() {
|
||||
}
|
||||
|
||||
&__return-home {
|
||||
display: block;
|
||||
float: left;
|
||||
display: block;
|
||||
width: 110px;
|
||||
height: 36px;
|
||||
font-size: 14px;
|
||||
|
||||
@@ -86,7 +86,8 @@
|
||||
<!-- 验证码 -->
|
||||
<el-form-item prop="captchaCode">
|
||||
<div class="input-wrapper">
|
||||
<svg-icon icon-class="captcha" class="mx-2" />
|
||||
<div class="i-svg:captcha mx-2" />
|
||||
|
||||
<el-input
|
||||
v-model="loginFormData.captchaCode"
|
||||
auto-complete="off"
|
||||
@@ -126,10 +127,10 @@
|
||||
<el-text size="small">{{ $t("login.otherLoginMethods") }}</el-text>
|
||||
</el-divider>
|
||||
<div class="third-party-login">
|
||||
<svg-icon icon-class="wechat" class="icon" />
|
||||
<svg-icon icon-class="qq" class="icon" />
|
||||
<svg-icon icon-class="github" class="icon" />
|
||||
<svg-icon icon-class="gitee" class="icon" />
|
||||
<div class="i-svg:wechat" />
|
||||
<div class="i-svg:qq" />
|
||||
<div class="i-svg:github" />
|
||||
<div class="i-svg:gitee" />
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
@@ -59,14 +59,14 @@
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item>
|
||||
<template #label>
|
||||
<SvgIcon icon-class="tree" />
|
||||
<div class="i-svg:tree" />
|
||||
部门
|
||||
</template>
|
||||
{{ userProfile.deptName }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item>
|
||||
<template #label>
|
||||
<SvgIcon icon-class="role" />
|
||||
<div class="i-svg:role" />
|
||||
角色
|
||||
</template>
|
||||
{{ userProfile.roleNames }}
|
||||
@@ -268,7 +268,7 @@ import { Camera } from "@element-plus/icons-vue";
|
||||
|
||||
const userProfile = ref<UserProfileVO>({});
|
||||
|
||||
enum DialogType {
|
||||
const enum DialogType {
|
||||
ACCOUNT = "account",
|
||||
PASSWORD = "password",
|
||||
MOBILE = "mobile",
|
||||
@@ -287,10 +287,10 @@ const mobileUpdateForm = reactive<MobileUpdateForm>({});
|
||||
const emailUpdateForm = reactive<EmailUpdateForm>({});
|
||||
|
||||
const mobileCountdown = ref(0);
|
||||
const mobileTimer = ref<NodeJS.Timeout | null>(null);
|
||||
const mobileTimer = ref();
|
||||
|
||||
const emailCountdown = ref(0);
|
||||
const emailTimer = ref<NodeJS.Timeout | null>(null);
|
||||
const emailTimer = ref();
|
||||
|
||||
// 修改密码校验规则
|
||||
const passwordChangeRules = {
|
||||
@@ -458,7 +458,7 @@ const handleFileChange = async (event: Event) => {
|
||||
if (file) {
|
||||
// 调用文件上传API
|
||||
try {
|
||||
const data = await FileAPI.upload(file);
|
||||
const data = await FileAPI.uploadFile(file);
|
||||
// 更新用户头像
|
||||
userProfile.value.avatar = data.url;
|
||||
// 更新用户信息
|
||||
@@ -466,6 +466,7 @@ const handleFileChange = async (event: Event) => {
|
||||
avatar: data.url,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("头像上传失败:" + error);
|
||||
ElMessage.error("头像上传失败");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,8 +137,8 @@ defineOptions({
|
||||
|
||||
import ConfigAPI, { ConfigPageVO, ConfigForm, ConfigPageQuery } from "@/api/system/config";
|
||||
|
||||
const queryFormRef = ref(ElForm);
|
||||
const dataFormRef = ref(ElForm);
|
||||
const queryFormRef = ref();
|
||||
const dataFormRef = ref();
|
||||
|
||||
const loading = ref(false);
|
||||
const selectIds = ref<number[]>([]);
|
||||
|
||||
@@ -160,8 +160,8 @@ defineOptions({
|
||||
|
||||
import DeptAPI, { DeptVO, DeptForm, DeptQuery } from "@/api/system/dept";
|
||||
|
||||
const queryFormRef = ref(ElForm);
|
||||
const deptFormRef = ref(ElForm);
|
||||
const queryFormRef = ref();
|
||||
const deptFormRef = ref();
|
||||
|
||||
const loading = ref(false);
|
||||
const selectIds = ref<number[]>([]);
|
||||
|
||||
@@ -144,8 +144,8 @@ const route = useRoute();
|
||||
|
||||
const dictCode = ref(route.query.dictCode as string);
|
||||
|
||||
const queryFormRef = ref(ElForm);
|
||||
const dataFormRef = ref(ElForm);
|
||||
const queryFormRef = ref();
|
||||
const dataFormRef = ref();
|
||||
|
||||
const loading = ref(false);
|
||||
const ids = ref<number[]>([]);
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
link
|
||||
size="small"
|
||||
icon="edit"
|
||||
@click.stop="handleEditClick(scope.row.id, scope.row.name)"
|
||||
@click.stop="handleEditClick(scope.row.id)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
@@ -133,8 +133,8 @@ import DictAPI, { DictPageQuery, DictPageVO, DictForm } from "@/api/system/dict"
|
||||
|
||||
import router from "@/router";
|
||||
|
||||
const queryFormRef = ref(ElForm);
|
||||
const dataFormRef = ref(ElForm);
|
||||
const queryFormRef = ref();
|
||||
const dataFormRef = ref();
|
||||
|
||||
const loading = ref(false);
|
||||
const ids = ref<number[]>([]);
|
||||
@@ -198,7 +198,7 @@ function handleAddClick() {
|
||||
*
|
||||
* @param id 字典ID
|
||||
*/
|
||||
function handleEditClick(id: number, name: string) {
|
||||
function handleEditClick(id: number) {
|
||||
dialog.visible = true;
|
||||
dialog.title = "修改字典";
|
||||
DictAPI.getFormData(id).then((data) => {
|
||||
|
||||
@@ -62,7 +62,7 @@ defineOptions({
|
||||
|
||||
import LogAPI, { LogPageVO, LogPageQuery } from "@/api/system/log";
|
||||
|
||||
const queryFormRef = ref(ElForm);
|
||||
const queryFormRef = ref();
|
||||
|
||||
const loading = ref(false);
|
||||
const total = ref(0);
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
</el-icon>
|
||||
</template>
|
||||
<template v-else-if="scope.row.icon">
|
||||
<svg-icon :icon-class="scope.row.icon" />
|
||||
<div :class="`i-svg:${scope.row.icon}`" />
|
||||
</template>
|
||||
{{ scope.row.name }}
|
||||
</template>
|
||||
@@ -338,8 +338,8 @@ defineOptions({
|
||||
import MenuAPI, { MenuQuery, MenuForm, MenuVO } from "@/api/system/menu";
|
||||
import { MenuTypeEnum } from "@/enums/MenuTypeEnum";
|
||||
|
||||
const queryFormRef = ref(ElForm);
|
||||
const menuFormRef = ref(ElForm);
|
||||
const queryFormRef = ref();
|
||||
const menuFormRef = ref();
|
||||
|
||||
const loading = ref(false);
|
||||
const dialog = reactive({
|
||||
|
||||
@@ -87,7 +87,7 @@ defineOptions({
|
||||
|
||||
import NoticeAPI, { NoticePageVO, NoticePageQuery } from "@/api/system/notice";
|
||||
|
||||
const queryFormRef = ref(ElForm);
|
||||
const queryFormRef = ref();
|
||||
const noticeDetailRef = ref();
|
||||
|
||||
const pageData = ref<NoticePageVO[]>([]);
|
||||
|
||||
@@ -13,8 +13,7 @@
|
||||
<div class="dialog-toolbar">
|
||||
<!-- 全屏/退出全屏按钮 -->
|
||||
<el-button circle @click="toggleFullscreen">
|
||||
<SvgIcon v-if="isFullscreen" icon-class="fullscreen-exit" />
|
||||
<SvgIcon v-else icon-class="fullscreen" />
|
||||
<div :class="`i-svg:${isFullscreen ? 'fullscreen-exit' : 'fullscreen'}`" />
|
||||
</el-button>
|
||||
<!-- 关闭按钮 -->
|
||||
<el-button circle @click="handleClose">
|
||||
|
||||
@@ -177,9 +177,7 @@
|
||||
<el-input v-model="formData.title" placeholder="通知标题" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="通知内容" prop="content">
|
||||
<div style="border: 1px solid #dcdfe6; border-radius: 4px">
|
||||
<WangEditor v-model="formData.content" style="min-height: 480px" />
|
||||
</div>
|
||||
<WangEditor v-model="formData.content" />
|
||||
</el-form-item>
|
||||
<el-form-item label="通知类型" prop="type">
|
||||
<Dict v-model="formData.type" code="notice_type" />
|
||||
@@ -225,8 +223,8 @@ defineOptions({
|
||||
import NoticeAPI, { NoticePageVO, NoticeForm, NoticePageQuery } from "@/api/system/notice";
|
||||
import UserAPI from "@/api/system/user";
|
||||
|
||||
const queryFormRef = ref(ElForm);
|
||||
const dataFormRef = ref(ElForm);
|
||||
const queryFormRef = ref();
|
||||
const dataFormRef = ref();
|
||||
const noticeDetailRef = ref();
|
||||
|
||||
const loading = ref(false);
|
||||
@@ -411,8 +409,3 @@ onMounted(() => {
|
||||
handleQuery();
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
.editor-wrapper {
|
||||
border: 1px solid #dcdfe6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -211,9 +211,9 @@ defineOptions({
|
||||
import RoleAPI, { RolePageVO, RoleForm, RolePageQuery } from "@/api/system/role";
|
||||
import MenuAPI from "@/api/system/menu";
|
||||
|
||||
const queryFormRef = ref(ElForm);
|
||||
const roleFormRef = ref(ElForm);
|
||||
const permTreeRef = ref<InstanceType<typeof ElTree>>();
|
||||
const queryFormRef = ref();
|
||||
const roleFormRef = ref();
|
||||
const permTreeRef = ref();
|
||||
|
||||
const loading = ref(false);
|
||||
const ids = ref<number[]>([]);
|
||||
|
||||
@@ -30,7 +30,7 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const deptList = ref<OptionType[]>(); // 部门列表
|
||||
const deptTreeRef = ref(ElTree); // 部门树
|
||||
const deptTreeRef = ref(); // 部门树
|
||||
const deptName = ref(); // 部门名称
|
||||
|
||||
const emits = defineEmits(["node-click"]);
|
||||
|
||||
@@ -175,8 +175,9 @@ const handleUpload = async () => {
|
||||
invalidCount.value = result.invalidCount;
|
||||
validCount.value = result.validCount;
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error("上传失败");
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
ElMessage.error("上传失败:" + error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
</el-table-column>
|
||||
<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="email" width="160" />
|
||||
<el-table-column label="状态" align="center" prop="status" width="80">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.status == 1 ? 'success' : 'info'">
|
||||
@@ -248,9 +249,8 @@ defineOptions({
|
||||
name: "User",
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const queryFormRef = ref(ElForm);
|
||||
const userFormRef = ref(ElForm);
|
||||
const queryFormRef = ref();
|
||||
const userFormRef = ref();
|
||||
|
||||
const queryParams = reactive<UserPageQuery>({
|
||||
pageNum: 1,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// uno.config.ts
|
||||
// https://unocss.nodejs.cn/guide/config-file
|
||||
import {
|
||||
defineConfig,
|
||||
presetAttributify,
|
||||
@@ -10,6 +10,25 @@ import {
|
||||
transformerVariantGroup,
|
||||
} 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({
|
||||
shortcuts: {
|
||||
"flex-center": "flex justify-center items-center",
|
||||
@@ -32,7 +51,22 @@ export default defineConfig({
|
||||
presets: [
|
||||
presetUno(),
|
||||
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(),
|
||||
presetWebFonts({
|
||||
fonts: {
|
||||
@@ -40,5 +74,6 @@ export default defineConfig({
|
||||
},
|
||||
}),
|
||||
],
|
||||
safelist: generateSafeList(),
|
||||
transformers: [transformerDirectives(), transformerVariantGroup()],
|
||||
});
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
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 Components from "unplugin-vue-components/vite";
|
||||
import { ElementPlusResolver } from "unplugin-vue-components/resolvers";
|
||||
|
||||
import { createSvgIconsPlugin } from "vite-plugin-svg-icons";
|
||||
import mockDevServerPlugin from "vite-plugin-mock-dev-server";
|
||||
|
||||
import UnoCSS from "unocss/vite";
|
||||
@@ -19,8 +18,9 @@ const __APP_INFO__ = {
|
||||
};
|
||||
|
||||
const pathSrc = resolve(__dirname, "src");
|
||||
|
||||
// Vite配置 https://cn.vitejs.dev/config
|
||||
export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
|
||||
export default defineConfig(({ mode }: ConfigEnv) => {
|
||||
const env = loadEnv(mode, process.cwd());
|
||||
return {
|
||||
resolve: {
|
||||
@@ -56,9 +56,7 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
|
||||
plugins: [
|
||||
vue(),
|
||||
env.VITE_MOCK_DEV_SERVER === "true" ? mockDevServerPlugin() : null,
|
||||
UnoCSS({
|
||||
hmrTopLevelAwait: false,
|
||||
}),
|
||||
UnoCSS(),
|
||||
// 自动导入配置 https://github.com/sxzz/element-plus-best-practices/blob/main/vite.config.ts
|
||||
AutoImport({
|
||||
// 导入 Vue 函数,如:ref, reactive, toRef 等
|
||||
@@ -88,11 +86,6 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
|
||||
dts: false,
|
||||
// dts: "src/types/components.d.ts",
|
||||
}),
|
||||
createSvgIconsPlugin({
|
||||
// 缓存图标位置
|
||||
iconDirs: [resolve(pathSrc, "assets/icons")],
|
||||
symbolId: "icon-[dir]-[name]",
|
||||
}),
|
||||
],
|
||||
// 预加载项目必需的组件
|
||||
optimizeDeps: {
|
||||
@@ -106,11 +99,17 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
|
||||
"sortablejs",
|
||||
"exceljs",
|
||||
"path-to-regexp",
|
||||
"echarts",
|
||||
"@wangeditor/editor",
|
||||
"@wangeditor/editor-for-vue",
|
||||
"echarts/core",
|
||||
"echarts/renderers",
|
||||
"echarts/charts",
|
||||
"echarts/components",
|
||||
"vue-i18n",
|
||||
"nprogress",
|
||||
"qs",
|
||||
"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-item/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/image-viewer/style/css",
|
||||
"element-plus/es/components/empty/style/css",
|
||||
"element-plus/es/components/message/style/css",
|
||||
],
|
||||
},
|
||||
// 构建配置
|
||||
|
||||
Reference in New Issue
Block a user