fix: 🐛 合并代码 解决冲突

This commit is contained in:
lostelk
2025-02-19 14:03:36 +08:00
78 changed files with 1906 additions and 2145 deletions

View File

@@ -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,
{

View File

@@ -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) 基于 Vue3ViteTypeScript 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 提交规范、常用组件封装。
- **系统功能:** 提供用户管理、角色管理、菜单管理、部门管理、字典管理等功能模块
- **权限管理:** 支持动态路由、按钮权限、角色权限和数据权限等多种权限管理方式。
- **基础设施:** 提供国际化、多布局、暗黑模式、全屏、水印、接口文档和代码生成器等功能。
- **持续更新**:项目持续开源更新,实时更新工具和依赖。
## 项目截图
## 项目预览
![](https://www.youlai.tech/storage/blog/2025/01/18/20250118160647.png)
![明亮模式](https://foruda.gitee.com/images/1709651876583793739/0ba1ee1c_716974.png)
![暗黑模式](https://foruda.gitee.com/images/1709651875494206224/2a2b0b53_716974.png)
![接口文档](https://foruda.gitee.com/images/1687755822857820115/96054330_716974.png)
![](https://www.youlai.tech/storage/blog/2025/01/18/20250118183539.png)
## 项目源码
@@ -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!
![](https://foruda.gitee.com/images/1728577513089814203/95f2a70d_716974.jpeg)
## 交流群🚀
## 加群交流
> **关注「有来技术」公众号,获取交流群二维码。**
> **关注「有来技术」公众号,点击菜单“交流群”获取加群二维码。**
>
> 如果交流群的二维码过期,请加微信(haoxianrui)备注「前端」、「后端」或「全栈」以获取最新二维码
> 如果二维码过期,请加微信(haoxianrui)备注「前端」、「后端」或「全栈」拉你进群
>
> 为确保交流群质量,防止营销广告人群混入,我们采取了此措施。望各位理解!
> 交流群仅限技术交流,为过滤广告营销暂设此门槛,感谢理解与配合
| 公众号 | 交流群 |
|:----:|:----:|
| ![有来技术公众号二维码](https://foruda.gitee.com/images/1687689212187063809/3c69eaee_716974.png) | ![交流群二维码](https://foruda.gitee.com/images/1687689212139273561/6a65ef69_716974.png) |
![有来技术公众号二维码](https://foruda.gitee.com/images/1737108820762592766/3390ed0d_716974.png)

View File

@@ -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",

View File

@@ -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", // 允许单个单词组件名
},
},
];

View File

@@ -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"
},

View File

@@ -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`,

View File

@@ -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",

View File

@@ -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";

View File

@@ -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];
}
}

View File

@@ -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];
}
}

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);

View File

@@ -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>

View File

@@ -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 });
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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
View 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,
};
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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: () => [],
},

View File

@@ -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;
}
}
}

View File

@@ -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>

View File

@@ -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); // 跳转到左侧第一个菜单
};
/**

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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);

View File

@@ -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);
},

View File

@@ -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") {
// 已登录,访问登录页,跳转到首页

View File

@@ -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
*/

View File

@@ -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();
})

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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 };

View File

@@ -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: "您的会话已过期,请重新登录",

View File

@@ -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();

View File

@@ -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) => {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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;
};

View File

@@ -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 },
{

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -89,8 +89,8 @@ function back() {
}
&__return-home {
display: block;
float: left;
display: block;
width: 110px;
height: 36px;
font-size: 14px;

View File

@@ -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>

View File

@@ -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("头像上传失败");
}
}

View File

@@ -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[]>([]);

View File

@@ -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[]>([]);

View File

@@ -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[]>([]);

View File

@@ -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) => {

View File

@@ -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);

View File

@@ -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({

View File

@@ -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[]>([]);

View File

@@ -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">

View File

@@ -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>

View File

@@ -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[]>([]);

View File

@@ -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"]);

View File

@@ -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);
}
};

View File

@@ -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,

View File

@@ -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()],
});

View File

@@ -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",
],
},
// 构建配置