master
LiuJiaNan 2025-10-22 11:19:51 +08:00
commit f801f7e8b6
61 changed files with 5403 additions and 0 deletions

13
.editorconfig Normal file
View File

@ -0,0 +1,13 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
insert_final_newline = false
trim_trailing_whitespace = false

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
/src/test/
/target/
.idea
/node_modules
*.local
env.d.ts
package-lock.json

42
.npmignore Normal file
View File

@ -0,0 +1,42 @@
# 开发相关文件
.eslintrc.cjs
.eslintignore
.prettierrc.cjs
.gitignore
vite.config.js
vitest.config.js
tsconfig.json
jsconfig.json
# 构建和缓存目录
node_modules/
dist/
.vite/
.cache/
# 开发工具配置
.vscode/
.idea/
.editorconfig
# 日志文件
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# 测试相关
coverage/
.nyc_output/
test/
tests/
__tests__/
*.test.js
*.test.ts
*.spec.js
*.spec.ts
# 其他
.DS_Store
.env*
!.env.example

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 LiuJiaNan
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

167
README.md Normal file
View File

@ -0,0 +1,167 @@
# zy-vue-library
## 📦 安装
```bash
# npm
npm install zy-vue-library
# yarn
yarn add zy-vue-library
# pnpm
pnpm add zy-vue-library
```
### 环境要求
- Node.js >= 18.0.0
- Vue >= 3.5.0
- Element Plus >= 2.11.0
## 🔨 使用
### 按需导入
```javascript
import { AppTable, AppFormBuilder, useListData } from 'zy-vue-library'
```
## 📖 组件
#### AppFormBuilder - 动态表单构建器
#### AppSearch - 搜索表单
#### AppUpload - 文件上传
#### AppEditor - 富文本编辑器
#### AppImportFile - Excel导入
#### AppMap - 地图选择点位
#### AppTable - 数据表格
#### AppPagination - 分页组件
#### AppInfoBuilder - 信息展示
#### AppVideo - 视频播放器
#### AppAliPlayer - 视频播放器
#### AppPreviewImg - 查看页面图片预览
#### AppPreviewPdf - 查看页面PDF预览
#### AppPdf - PDF预览
#### AppTooltipImg - 表格列中预览图片
#### AppTxt - 预览txt文件
#### AppQrCode - 二维码生成
#### AppSign - 电子签名
#### AppVerification - 滑块验证码
#### AppVerificationCode - 数字验证码
#### AppViewTree - 左侧树形菜单
#### AppCascader - 级联选择
## 🖼️ 布局
#### AppLayout - 默认布局
## 🛠️ Hooks
#### useDataDictionary - 获取数据字典
#### useListData - 列表数据管理
#### useForm - 表单操作
#### useDownloadFile - 文件下载
#### useDownloadBlob - 文件下载Blob
#### useIsExistenceDuplicateSelection - 判断数组中是否存在重复项
#### useQueryCriteria - 查询条件缓存
#### useRequestLoading - 请求加载状态
#### useUploadFile - 上传附件
## 🔨 工具函数
#### serialNumber(pagination, index) - 计算表格序号
#### numFormat(num) - 千位分隔符格式化
#### randoms(min, max) - 生成指定范围随机数
#### secondConversion(second) - 秒转时分秒
#### calculateFileSize(size) - 计算文件大小
#### ArrayDeduplication(arr) - 数组去重
#### arrayObjectDeduplication(arr, name) - 数组对象去重
#### toArrayString(value) - 字符串数组转数组
#### paging(list, currentPage, pageSize) - 数据分页
#### getSelectAppointItemList(list, value, idKey) - 获取指定项数组
#### listTransTree(json, idStr, pidStr, childrenStr) - JSON转树形结构
#### getFileName(name) - 获取文件名
#### getFileSuffix(name) - 获取文件后缀
#### interceptTheSuffix(name, suffix) - 判断文件后缀
#### findCharIndex(str, char, num) - 查找字符位置
#### getUrlParam(name) - 获取URL参数
#### isEmpty(value) - 验证是否为空
#### getDataType(data) - 获取数据类型
#### isEmptyToWhether(value, options) - 值转换为是否显示
#### image2Base64(imgUrl) - 图片转base64
#### image2Base642(file) - 文件转base64
#### checkImgExists(imgUrl) - 检查图片是否可访问
#### readTxtDocument(filePath) - 读取文本文档
#### getLabelName(status, list, idKey, nameKey) - 翻译状态
#### idCardGetDateAndGender(idCard) - 身份证号获取信息
#### addingPrefixToFile(list, options) - 文件添加前缀
#### verifyDuplicateSelection(list, index, key, id) - 验证重复选择
#### getRowSpans(data, field, rowIndex) - 计算表格合并行
#### createGuid(len) - 生成GUID
#### getFileUrl() - 获取文件前缀地址
#### getBaseUrl() - 获取基础URL
#### getWebUrl() - 获取当前页面URL
## 🎯 正则表达式
#### PHONE - 匹配中国手机号码可包含国家代码86支持各种运营商号段。
#### UNIFIED_SOCIAL_CREDIT_CODE - 匹配中国大陆的统一社会信用代码。
#### ID_NUMBER - 匹配中国大陆的身份证号码包括15位和18位号码并验证最后一位校验码。
#### MOBILE_PHONE - 匹配中国大陆的移动电话号码,不包含国家代码。
#### FLOATING_POINT_NUMBER - 匹配浮点数,允许整数、一位或两位小数,以及零的情况。
#### ONE_DECIMAL_PLACES - 两位小数。
#### TWO_DECIMAL_PLACES - 一位小数(非必须)。
#### LICENSE_PLATE_NUMBER - 匹配中国大陆的车牌号码。
#### STRONG_PASSWORD - 匹配强密码要求至少8个字符包含大小写字母、数字和特殊字符。
#### HTML_TAG - 匹配完整的HTML标签包括开始标签和结束标签。
## 🗃️ 状态管理
### useQueryCriteriaStore - 查询条件缓存
#### getQueryCriteria - 获取缓存的查询条件
#### setQueryCriteria - 设置缓存的查询条件
#### resetQueryCriteria - 清空缓存的查询条件
#### getTabsActiveName - 获取当前激活的 tabs 名称
#### setTabsActiveName - 设置当前激活的 tabs 名称
## 🎛️ 指令
#### v-permission - 权限指令
## 🔄 动态路由
#### configureDynamicRouter - 配置动态路由
#### resetDynamicRouter - 重置动态路由
#### getStorageRouter - 获取存储的动态路由
## 🔐 AES加密服务
#### configureAesSecret - 配置AES加密服务
#### aesEncrypt - 加密
#### aesDecrypt - 解密
## 🌐 Axios
#### configureAxios - 配置Axios
#### postRequest - post请求
#### getRequest - get请求
#### putRequest - put请求
#### deleteRequest - delete请求
#### patchRequest - patch请求
#### uploadRequest - upload请求
## 📋 枚举
#### formItemTypeEnum - 表单类型枚举
## 📄 更新日志
### v1.0.0 (2025-09-22)
- 🎉 初始版本发布
### v1.1.0 (2025-09-29)
- 🚀 稳定版本发布

111
aesSecret/index.js Normal file
View File

@ -0,0 +1,111 @@
import CryptoJS from "crypto-js";
/**
* AES加密服务类
*/
class AESSecretService {
constructor() {
/**
* 默认配置
*/
this.config = {
key: "", // 加密密钥
encryptKey: "", // 加密解密密钥
decryptKey: "", // 加密密钥
};
}
/**
* 配置AES加密密钥
* @param {Object} config - 配置选项
* @param {string} [config.key] - 加密解密密钥
* @param {string} [config.encryptKey] - 加密密钥
* @param {string} [config.decryptKey] - 解密密钥
*/
configure(config) {
if (!config) throw new Error('配置不能为空');
if (!config.key && !config.encryptKey && !config.decryptKey) {
throw new Error('key、encryptKey、decryptKey 必须传入一个');
}
this.config = { ...this.config, ...config };
if (this.config.key) {
this.encryptKey = CryptoJS.enc.Utf8.parse(this.config.key);
this.decryptKey = CryptoJS.enc.Utf8.parse(this.config.key);
} else {
this.encryptKey = CryptoJS.enc.Utf8.parse(this.config.encryptKey);
this.decryptKey = CryptoJS.enc.Utf8.parse(this.config.decryptKey);
}
}
/**
* 加密方法
* @param {string|Object} word - 要加密的内容
* @returns {string} - 加密后的字符串
*/
encrypt(word) {
let encrypted = "";
if (typeof word === "string") {
const src = CryptoJS.enc.Utf8.parse(word);
encrypted = CryptoJS.AES.encrypt(src, this.encryptKey, {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7,
});
} else if (typeof word === "object") {
// 对象格式的转成json字符串
const data = JSON.stringify(word);
const src = CryptoJS.enc.Utf8.parse(data);
encrypted = CryptoJS.AES.encrypt(src, this.encryptKey, {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7,
});
}
return encrypted.ciphertext.toString(CryptoJS.enc.Base64);
}
/**
* 解密方法
* @param {string} params - 要解密的内容
* @returns {string} - 解密后的字符串
*/
decrypt(params) {
const bytes = CryptoJS.AES.decrypt(params, this.decryptKey, {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7
});
const decryptedText = bytes.toString(CryptoJS.enc.Utf8);
return JSON.parse(decryptedText);
}
}
/**
* 配置AES加密服务
* @param {Object} config - 配置选项
* @param {string} [config.key] - 加密解密密钥
* @param {string} [config.encryptKey] - 加密密钥
* @param {string} [config.decryptKey] - 加密密钥
*/
export function configureAesSecret(config) {
if (!window.aesSecretService) window.aesSecretService = new AESSecretService();
return window.aesSecretService.configure(config);
}
/**
* 加密方法
* @param {string|Object} word - 要加密的内容
* @returns {string} - 加密后的字符串
*/
export function aesEncrypt(word) {
if (!window.aesSecretService) throw new Error('未配置AES加密服务请先调用 configureAesSecret');
return window.aesSecretService.encrypt(word);
}
/**
* 解密方法
* @param {string} params - 要解密内容
* @returns {string} - 解密后的字符串
*/
export function aesDecrypt(params) {
if (!window.aesSecretService) throw new Error('未配置AES加密服务请先调用 configureAesSecret');
return window.aesSecretService.decrypt(params);
}

408
axios/index.js Normal file
View File

@ -0,0 +1,408 @@
import axios from "axios";
import { ElMessage } from "element-plus";
import dayjs from 'dayjs'
import useRequestLoading from "../hooks/useRequestLoading/index.js";
import { getBaseUrl } from "../utils/index.js";
import CryptoJS from "crypto-js";
import {aesDecrypt} from "../aesSecret/index.js";
/**
* Axios服务类
*/
class AxiosService {
constructor() {
/**
* Axios配置选项
*/
this.config = {
router: null,
store: null,
tokenRefreshUrl: '/sys/refreshToken',
loginPath: '/login',
tokenRefreshInterval: 5, // 分钟
baseURL: getBaseUrl(),
timeout: 1000 * 60 * 10,
requestParamsSign: {
use: false,
key: ''
},
responseParamsDecrypt: {
use: false,
}
};
this.loading = useRequestLoading();
this.isTipTokenFailure = false;
}
/**
* 配置axios插件
* @param {Object} [config] - 配置选项
* @param {Object} config.router - Vue Router实例
* @param {Object} config.store - token的piniaStore
* @param {string} [config.tokenRefreshUrl="/sys/refreshToken"] - token刷新接口URL
* @param {string} [config.loginPath="/login"] - 登录页面路由
* @param {number} [config.tokenRefreshInterval=5] - token刷新间隔分钟
* @param {string} [config.baseURL=getBaseUrl()] - API基础URL
* @param {number} [config.timeout=60000] - 请求超时时间毫秒
* @param {Object} [config.requestParamsSign] - 请求参数是否签名
* @param {boolean} [config.requestParamsSign.use=false] - 是否使用签名
* @param {string} [config.requestParamsSign.key] - 签名密钥
* @param {Object} [config.responseParamsDecrypt] - 响应参数是否需要解密
* @param {boolean} [config.responseParamsDecrypt.use=false] - 是否需要解密
*/
configure(config = {}) {
if (!config.router) throw new Error('router 参数必传');
if (!config.store) throw new Error('store 参数必传');
this.config = { ...this.config, ...config };
// 设置默认配置
axios.defaults.baseURL = this.config.baseURL;
axios.defaults.timeout = this.config.timeout;
// 清除现有拦截器
axios.interceptors.request.clear();
axios.interceptors.response.clear();
// 重新设置拦截器
this.setupInterceptors();
}
/**
* token刷新逻辑
*/
async refreshToken() {
const currentRoute = this.config.router.currentRoute?.value;
if (currentRoute?.meta?.isLogin) {
const store = this.config.store;
if (store.getTokenTime) {
const diffMinutes = dayjs().diff(dayjs(store.getTokenTime), "minute");
if (diffMinutes >= this.config.tokenRefreshInterval) {
await store.setTokenTime(dayjs().format("YYYY-MM-DD HH:mm:ss"));
await this.postRequest(this.config.tokenRefreshUrl);
}
}
}
}
/**
* 请求参数签名
*/
requestParamsSign(params) {
if (!this.config.requestParamsSign.key) throw new Error('请传入签名密钥');
const Timestamp = new Date().getTime();
const cloneParams = { ...params };
const keys = Object.keys(cloneParams).sort();
const sortData = {};
for (let i = 0; i < keys.length; i++) {
if (cloneParams[keys[i]] !== null && cloneParams[keys[i]] !== undefined) {
sortData[keys[i]] = cloneParams[keys[i]];
}
}
const Sign = CryptoJS.MD5(
Timestamp +
JSON.stringify(sortData) +
this.config.requestParamsSign.key
).toString();
return {
Sign,
Timestamp,
};
}
/**
* 设置拦截器
*/
setupInterceptors() {
// 请求拦截器
axios.interceptors.request.use(
async (request) => {
request.headers.Token = this.config.store.getToken;
let params = request.data || request.params || {};
// 根据配置决定是否启用参数签名
if (this.config.requestParamsSign.use) {
const { Timestamp, Sign } = this.requestParamsSign(params);
request.headers.Timestamp = Timestamp;
request.headers.Sign = Sign;
}
this.loading.value = true;
return request;
},
(error) => Promise.reject(error)
);
// 响应拦截器
axios.interceptors.response.use(
(response) => {
if (this.config.responseParamsDecrypt.use) response.data = { ...response.data, ...aesDecrypt(response.data.data)}
this.loading.value = false;
if (response.data.code === 401) {
if (!this.isTipTokenFailure) {
this.isTipTokenFailure = true;
ElMessage.error("登录失效,请重新登录");
this.config.router.push(this.config.loginPath);
this.isTipTokenFailure = false;
}
return Promise.reject(response.data.msg);
} else {
this.refreshToken().then();
}
return response;
},
(error) => {
if (error && error.response) {
if (import.meta.env.DEV) ElMessage.error(`连接错误${error.response.status}`);
} else ElMessage.error(error.message);
return Promise.reject(error.message);
}
);
}
/**
* 发送POST请求
* @param {string} url - 请求URL
* @param {Object} params - 请求参数
* @returns {Promise} - 返回Promise对象包含请求结果
*/
postRequest(url, params = {}) {
return new Promise((resolve, reject) => {
axios
.post(url, params)
.then((res) => {
if (res.data.result === "success") {
resolve(res.data);
} else {
ElMessage.error(res.data.msg || "系统开小差了");
reject(res.data);
}
})
.catch((err) => {
reject(err);
});
});
}
/**
* 获取GET请求
* @param {string} url - 请求URL
* @param {Object} params - 请求参数
* @param {string} splicingURL - URL拼接字符串
* @returns {Promise} - 返回Promise对象包含请求结果
*/
getRequest(url, params = {}, splicingURL = "") {
return new Promise((resolve, reject) => {
axios
.get(url + (splicingURL ? "/" + splicingURL : ""), {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
params,
})
.then((res) => {
if (res.data.result === "success") {
resolve(res.data);
} else {
ElMessage.error(res.data.msg || "系统开小差了");
reject(res.data);
}
})
.catch((err) => {
reject(err);
});
});
}
/**
* 获取PUT请求
* @param {string} url - 请求URL
* @param {Object} params - 请求参数
* @returns {Promise} - 返回Promise对象包含请求结果
*/
putRequest(url, params = {}) {
return new Promise((resolve, reject) => {
axios
.put(url, params)
.then((res) => {
if (res.data.result === "success") {
resolve(res.data);
} else {
ElMessage.error(res.data.msg || "系统开小差了");
reject(res.data);
}
})
.catch((err) => {
reject(err);
});
});
}
/**
* 获取DELETE请求
* @param {string} url - 请求URL
* @param {Object} params - 删除参数
* @param {string} splicingURL - URL拼接字符串
* @returns {Promise} - 返回Promise对象包含请求结果
*/
deleteRequest(url, params = {}, splicingURL = "") {
return new Promise((resolve, reject) => {
axios
.delete(url + (splicingURL ? "/" + splicingURL : ""), { data: params })
.then((res) => {
if (res.data.result === "success") {
resolve(res.data);
} else {
ElMessage.error(res.data.msg || "系统开小差了");
reject(res.data);
}
})
.catch((err) => {
reject(err);
});
});
}
/**
* 获取PATCH请求
* @param {string} url - 请求URL
* @param {Object} params - 请求参数
* @param {string} splicingURL - URL拼接字符串
* @returns {Promise} - 返回Promise对象包含请求结果
*/
patchRequest(url, params = {}, splicingURL = "") {
return new Promise((resolve, reject) => {
axios
.patch(url + (splicingURL ? "/" + splicingURL : ""), params)
.then((res) => {
if (res.data.result === "success") {
resolve(res.data);
} else {
ElMessage.error(res.data.msg || "系统开小差了")
reject(res.data);
}
})
.catch((err) => {
reject(err);
});
});
}
/**
* 上传文件请求
* @param {string} url - 请求URL
* @param {Object} params - 请求参数
* @returns {Promise} - 返回Promise对象包含请求结果
*/
uploadRequest(url, params = {}) {
return new Promise((resolve, reject) => {
axios
.post(url, params, {
headers: {
"Content-Type": "multipart/form-data",
},
})
.then((res) => {
if (res.data.result === "success") {
resolve(res.data);
} else {
ElMessage.error(res.data.msg || "系统开小差了");
reject(res.data);
}
})
.catch((err) => {
reject(err);
});
});
}
}
/**
* 配置axios插件
* @param {Object} [config] - 配置选项
* @param {Object} config.router - Vue Router实例
* @param {Object} config.store - token的piniaStore
* @param {string} [config.tokenRefreshUrl="/sys/refreshToken"] - token刷新接口URL
* @param {string} [config.loginPath="/login"] - 登录页面路由
* @param {number} [config.tokenRefreshInterval=5] - token刷新间隔分钟
* @param {string} [config.baseURL=getBaseUrl()] - API基础URL
* @param {number} [config.timeout=60000] - 请求超时时间毫秒
* @param {Object} [config.requestParamsSign] - 请求参数是否签名
* @param {boolean} [config.requestParamsSign.use=false] - 是否使用签名
* @param {string} [config.requestParamsSign.key] - 签名密钥
* @param {Object} [config.responseParamsDecrypt] - 响应参数是否需要解密
* @param {boolean} [config.responseParamsDecrypt.use=false] - 是否需要解密
*/
export function configureAxios(config = {}) {
if(!window.axiosService) window.axiosService = new AxiosService();
return axiosService.configure(config);
}
/**
* 发送POST请求
* @param {string} url - 请求URL
* @param {Object} params - 请求参数
* @returns {Promise} - 返回Promise对象包含请求结果
*/
export function postRequest(url, params = {}) {
if (!window.axiosService) throw new Error('未配置Axios请先调用 configureAxios');
return window.axiosService.postRequest(url, params);
}
/**
* 获取GET请求
* @param {string} url - 请求URL
* @param {Object} params - 请求参数
* @param {string} splicingURL - URL拼接字符串
* @returns {Promise} - 返回Promise对象包含请求结果
*/
export function getRequest(url, params = {}, splicingURL = "") {
if (!window.axiosService) throw new Error('未配置Axios请先调用 configureAxios');
return window.axiosService.getRequest(url, params, splicingURL);
}
/**
* 获取PUT请求
* @param {string} url - 请求URL
* @param {Object} params - 请求参数
* @returns {Promise} - 返回Promise对象包含请求结果
*/
export function putRequest(url, params = {}) {
if (!window.axiosService) throw new Error('未配置Axios请先调用 configureAxios');
return window.axiosService.putRequest(url, params);
}
/**
* 获取DELETE请求
* @param {string} url - 请求URL
* @param {Object} params - 删除参数
* @param {string} splicingURL - URL拼接字符串
* @returns {Promise} - 返回Promise对象包含请求结果
*/
export function deleteRequest(url, params = {}, splicingURL = "") {
if (!window.axiosService) throw new Error('未配置Axios请先调用 configureAxios');
return window.axiosService.deleteRequest(url, params, splicingURL);
}
/**
* 获取PATCH请求
* @param {string} url - 请求URL
* @param {Object} params - 请求参数
* @param {string} splicingURL - URL拼接字符串
* @returns {Promise} - 返回Promise对象包含请求结果
*/
export function patchRequest(url, params = {}, splicingURL = "") {
if (!window.axiosService) throw new Error('未配置Axios请先调用 configureAxios');
return window.axiosService.patchRequest(url, params, splicingURL);
}
/**
* 上传文件请求
* @param {string} url - 请求URL
* @param {Object} params - 请求参数
* @returns {Promise} - 返回Promise对象包含请求结果
*/
export function uploadRequest(url, params = {}) {
if (!window.axiosService) throw new Error('未配置Axios请先调用 configureAxios');
return window.axiosService.uploadRequest(url, params);
}

View File

@ -0,0 +1,152 @@
<template>
<div :id="className" :style="{ width, height }" />
</template>
<script setup>
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useDocumentVisibility } from "@vueuse/core";
import { uniqueId } from "lodash-es";
let player = null;
defineOptions({
name: "AppAliPlayer",
});
const props = defineProps({
source: { type: [String, Array], default: "" },
vid: { type: String, default: "" },
playAuth: { type: String, default: "" },
cover: { type: String, default: "" },
width: { type: String, default: "100%" },
height: { type: String, default: "600px" },
visible: { type: Boolean, default: false },
autoplay: { type: Boolean, default: true },
showProgress: { type: Boolean, default: true },
isLive: { type: Boolean, default: false },
playTime: { type: Number, default: 0 },
});
const emits = defineEmits(["ended", "timeupdate"]);
const visibility = useDocumentVisibility();
const className = ref(uniqueId("_"));
onMounted(() => {
watch(
[() => props.source, () => props.vid, () => props.playAuth],
() => {
if (props.source || (props.vid && props.playAuth)) fnCreateAliPlayer();
},
{
immediate: true,
}
);
watch(
() => visibility.value,
() => {
if (visibility.value === "hidden") {
props.visible && pause();
} else {
props.visible && play();
}
},
{
immediate: true,
}
);
});
const fnCreateAliPlayer = async () => {
await nextTick();
fnDisposeAliPlayer();
const skinLayout = [
{ name: "bigPlayButton", align: "blabs", x: 30, y: 80 },
{ name: "H5Loading", align: "cc" },
{ name: "errorDisplay", align: "tlabs", x: 0, y: 0 },
{ name: "infoDisplay" },
{ name: "tooltip", align: "blabs", x: 0, y: 56 },
{ name: "thumbnail" },
{
name: "controlBar",
align: "blabs",
x: 0,
y: 0,
children: [
{ name: "playButton", align: "tl", x: 15, y: 12 },
{ name: "timeDisplay", align: "tl", x: 10, y: 7 },
{ name: "fullScreenButton", align: "tr", x: 10, y: 12 },
{ name: "setting", align: "tr", x: 15, y: 12 },
{ name: "volume", align: "tr", x: 5, y: 10 },
],
},
];
props.showProgress &&
skinLayout.at(-1).children.unshift({
name: "progress",
align: "blabs",
x: 0,
y: 44,
});
player = new window.Aliplayer(
{
id: className.value,
...(props.source
? { source: props.source }
: {
vid: props.vid,
playauth: props.playAuth,
qualitySort: "asc",
format: "m3u8",
encryptType: 1,
mediaType: "video",
isLive: true,
rePlay: false,
playsinline: true,
controlBarVisibility: "hover",
}),
cover: props.cover,
width: props.width,
height: props.height,
autoplay: props.autoplay,
isLive: props.isLive,
useH5Prism: true,
skinLayout,
},
(player) => {
props.autoplay && player.play();
player.on("ended", fnPlayerEnded);
player.on("timeupdate", fnPlayTimeUpdate);
player.seek(props.playTime);
}
);
};
const fnPlayerEnded = () => {
emits("ended");
};
const fnPlayTimeUpdate = () => {
emits("timeupdate", player.getCurrentTime());
};
const fnDisposeAliPlayer = () => {
if (!player) return;
player.dispose();
player = null;
};
onBeforeUnmount(() => {
fnDisposeAliPlayer();
});
const play = () => {
player && player.play();
};
const pause = () => {
player && player.pause();
};
defineExpose({
play,
pause,
});
</script>
<style scoped lang="scss">
:deep {
.prism-setting-speed,
.prism-setting-cc,
.prism-setting-audio {
display: none;
}
}
</style>

View File

@ -0,0 +1,62 @@
<template>
<el-cascader ref="cascaderRef" v-model="modelValue" :props="cascaderProps" />
</template>
<script setup>
import { ref } from "vue";
import { postRequest } from "../../axios/index.js";
import { ElCascader } from 'element-plus'
import "element-plus/es/components/cascader/style/css";
defineOptions({
name: "AppCascader",
});
const props = defineProps({
id: { type: String, required: true, default: "" },
level: { type: [Number, String], default: 3 },
checkStrictly: { type: Boolean, default: true },
showAllLevels: { type: Boolean, default: true },
value: { type: String, default: "dictionariesId" },
joinSeparator: { type: String, default: "/" },
controlLevel: { type: Boolean, default: false },
});
const modelValue = defineModel({ type: Array, required: true });
const cascaderRef = ref(null);
const cascaderProps = {
lazy: true,
lazyLoad: async (node, resolve) => {
const { dictionariesList } = await postRequest("/sys/dictionaries/list", {
parentId: node.data.dictionariesId || props.id,
});
resolve(
dictionariesList.map((item) => {
return {
dictionariesId: item.dictionariesId,
bianma: item.bianma,
name: item.name,
leaf: props.controlLevel
? node.level >= props.level
: item.hasChildren === 0,
};
})
);
},
value: props.value,
id: "dictionariesId",
label: "name",
children: "children",
checkStrictly: props.checkStrictly,
};
const getCheckedNodes = () => {
return (
cascaderRef.value
.getCheckedNodes()[0]
?.pathLabels.join(props.joinSeparator) || ""
);
};
defineExpose({
getCheckedNodes,
});
</script>
<style scoped></style>

View File

@ -0,0 +1,9 @@
<template>
<router-view />
</template>
<script setup>
defineOptions({
name: "Children",
});
</script>

View File

@ -0,0 +1,41 @@
<template>
<div style="flex: 1">
<toolbar :editor="editorRef" :default-config="toolbarConfig" />
<editor v-model="modelValue" :style="{ height, 'overflow-y': 'hidden' }" @on-created="fnEditorCreated" />
</div>
</template>
<script setup>
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
import "@wangeditor/editor/dist/css/style.css";
import { shallowRef, onBeforeUnmount } from "vue";
defineOptions({
name: "AppEditor",
});
defineProps({
height: { type: String, default: "350px" },
});
const modelValue = defineModel({ type: String, required: true });
const editorRef = shallowRef();
const toolbarConfig = {
excludeKeys: [
"color",
"bgColor",
"group-image",
"group-video",
"insertLink",
"codeBlock",
"emotion",
"todo",
],
};
const fnEditorCreated = (editor) => {
editorRef.value = editor;
};
onBeforeUnmount(() => {
editorRef.value && editorRef.value.destroy();
});
</script>
<style scoped></style>

View File

@ -0,0 +1,272 @@
<template>
<template v-for="(option, index) in fnVerifyType(options)" :key="option.key || index">
<template v-if="!option.hidden">
<el-col
v-if="!option.root"
v-show="!collapse || index < 3"
:span="option.type === formItemTypeEnum.DIVIDER ? 24 : option.span || span || 12"
>
<el-form-item
v-if="option.type !== formItemTypeEnum.DIVIDER"
:key="option.formItemKey"
:label-width="option.labelWidth"
:prop="option.key"
:rules="fnGetRules(option)"
>
<template #label>
<div style="display: flex; align-items: center">
{{ option.label }}
<el-tooltip v-if="option.tip" :content="option.tip">
<el-icon :size="12" class="ml-2"><info-filled /></el-icon>
</el-tooltip>
</div>
</template>
<slot :name="option.key">
<el-input
v-if="option.type === formItemTypeEnum.INPUT || !option.type"
v-model="modelValue[option.key]"
:placeholder="'请输入' + option.label"
v-bind="fnGetProps(option)"
/>
<el-input
v-else-if="option.type === formItemTypeEnum.TEXTAREA"
v-model="modelValue[option.key]"
:placeholder="'请输入' + option.label"
type="textarea"
:autosize="{ minRows: 3 }"
v-bind="fnGetProps(option)"
/>
<el-input-number
v-else-if="
option.type === formItemTypeEnum.INPUT_NUMBER || option.type === formItemTypeEnum.NUMBER
"
v-model="modelValue[option.key]"
:placeholder="'请输入' + option.label"
v-bind="fnGetProps(option)"
/>
<el-select
v-else-if="option.type === formItemTypeEnum.SELECT"
v-model="modelValue[option.key]"
:placeholder="'请选择' + option.label"
v-bind="fnGetProps(option)"
>
<el-option
v-for="item in unref(option.options)"
:key="
item[option.valueKey] || item['dictionariesId'] || item['id']
"
:label="item[option.labelKey] || item['name']"
:value="
item[option.valueKey] || item['dictionariesId'] || item['id']
"
/>
</el-select>
<el-checkbox-group
v-else-if="option.type === formItemTypeEnum.CHECKBOX"
v-model="modelValue[option.key]"
:placeholder="'请选择' + option.label"
v-bind="fnGetProps(option)"
>
<el-checkbox
v-for="item in unref(option.options)"
:key="
item[option.valueKey] || item['dictionariesId'] || item['id']
"
:value="
item[option.valueKey] || item['dictionariesId'] || item['id']
"
>
{{ item[option.labelKey] || item["name"] }}
</el-checkbox>
</el-checkbox-group>
<el-radio-group
v-else-if="option.type === formItemTypeEnum.RADIO"
v-model="modelValue[option.key]"
:placeholder="'请选择' + option.label"
v-bind="fnGetProps(option)"
>
<el-radio
v-for="item in unref(option.options)"
:key="
item[option.valueKey] || item['dictionariesId'] || item['id']
"
:value="
item[option.valueKey] || item['dictionariesId'] || item['id']
"
>
{{ item[option.labelKey] || item["name"] }}
</el-radio>
</el-radio-group>
<el-date-picker
v-else-if="option.type === formItemTypeEnum.DATE"
v-model="modelValue[option.key]"
value-format="YYYY-MM-DD"
:placeholder="'请选择' + option.label"
v-bind="fnGetProps(option)"
/>
<el-date-picker
v-else-if="option.type === formItemTypeEnum.DATE_MONTH"
v-model="modelValue[option.key]"
type="month"
value-format="YYYY-MM"
:placeholder="'请选择' + option.label"
v-bind="fnGetProps(option)"
/>
<el-date-picker
v-else-if="option.type === formItemTypeEnum.DATE_YEAR"
v-model="modelValue[option.key]"
type="year"
value-format="YYYY"
:placeholder="'请选择' + option.label"
v-bind="fnGetProps(option)"
/>
<el-date-picker
v-else-if="option.type === formItemTypeEnum.DATE_RANGE"
v-model="modelValue[option.key]"
type="daterange"
value-format="YYYY-MM-DD"
:start-placeholder="'请选择开始' + option.label"
:end-placeholder="'请选择结束' + option.label"
v-bind="fnGetProps(option)"
/>
<el-date-picker
v-else-if="option.type === formItemTypeEnum.DATETIME"
v-model="modelValue[option.key]"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
:placeholder="'请选择' + option.label"
v-bind="fnGetProps(option)"
/>
<el-date-picker
v-else-if="option.type === formItemTypeEnum.DATETIME_RANGE"
v-model="modelValue[option.key]"
type="datetimerange"
value-format="YYYY-MM-DD HH:mm:ss"
:start-placeholder="'请选择开始' + option.label"
:end-placeholder="'请选择结束' + option.label"
:default-time="[
new Date(2000, 1, 1, 0, 0, 0),
new Date(2000, 2, 1, 23, 59, 59),
]"
v-bind="fnGetProps(option)"
/>
<component :is="option.type" v-else v-model="modelValue[option.key]" v-bind="fnGetProps(option)" />
</slot>
</el-form-item>
<el-divider v-if="option.type === formItemTypeEnum.DIVIDER" content-position="left">
{{ option.label }}
</el-divider>
</el-col>
<template v-if="option.root">
<slot :name="option.key">
<component :is="option.type" v-model="modelValue[option.key]" v-bind="fnGetProps(option)" />
</slot>
</template>
</template>
</template>
</template>
<script setup>
import { omit } from "lodash-es";
import { unref } from "vue";
import { InfoFilled } from "@element-plus/icons-vue";
import {
ElCol,
ElFormItem,
ElTooltip,
ElIcon,
ElInput,
ElInputNumber,
ElSelect,
ElOption,
ElCheckboxGroup,
ElCheckbox,
ElRadioGroup,
ElRadio,
ElDatePicker,
ElDivider,
} from "element-plus";
import "element-plus/es/components/col/style/css";
import "element-plus/es/components/form-item/style/css";
import "element-plus/es/components/tooltip/style/css";
import "element-plus/es/components/icon/style/css";
import "element-plus/es/components/input/style/css";
import "element-plus/es/components/input-number/style/css";
import "element-plus/es/components/select/style/css";
import "element-plus/es/components/option/style/css";
import "element-plus/es/components/checkbox-group/style/css";
import "element-plus/es/components/checkbox/style/css";
import "element-plus/es/components/radio-group/style/css";
import "element-plus/es/components/radio/style/css";
import "element-plus/es/components/date-picker/style/css";
import "element-plus/es/components/divider/style/css";
import formItemTypeEnum from "../../enum/formItemType/index.js";
const props = defineProps({
options: { type: Array, required: true },
span: { type: Number, default: 0 },
collapse: { type: Boolean, default: false },
autoGenerateRequired: { type: Boolean, default: true },
});
const modelValue = defineModel({ type: Object, required: true });
const rootProps = [
"label", //
"key", // formkey
"type", // input
"span", // 12
"hidden", //
"rules", //
"options", // selectradiocheckbox
"valueKey", // selectradiocheckbox value
"labelKey", // selectradiocheckbox label
"attrs", //
"root", // el-col
"labelWidth", // label
"tip", // label
"formItemKey", // el-form-itemkey
"required", // el-form-itemtrue
// ... // attrs使attrs
];
const fnGetProps = (option) => {
if (option.attrs) return option.attrs;
return omit(option, rootProps);
};
const fnGetRules = (option) => {
if (props.autoGenerateRequired === false) return {};
if (option.type === formItemTypeEnum.DIVIDER) return {};
if (option.required !== false) {
const blur = option.type
? [
formItemTypeEnum.INPUT,
formItemTypeEnum.TEXTAREA,
formItemTypeEnum.INPUT_NUMBER,
formItemTypeEnum.NUMBER,
].includes(option.type)
: true;
const rules = [
{
required: true,
message: `${blur ? "请输入" : "请选择"}${option.label}`,
trigger: blur ? "blur" : "change",
},
];
if (option.rules) {
if (Array.isArray(option.rules)) rules.push(...option.rules);
else rules.push(option.rules);
}
return rules;
}
return {};
};
const fnVerifyType = (options) => {
for (let i = 0; i < options.length; i++) {
const option = options[i];
if (option.type && typeof option.type === 'string' && !formItemTypeEnum[option.type])
throw new Error(`${option.type} 类型枚举不存在,请参照并使用 formItemTypeEnum 类型枚举`);
}
return options;
};
</script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,41 @@
<template>
<el-form ref="formRef" :model="modelValue" :rules="rules" :label-width="labelWidth" :label-position="labelPosition">
<el-row :gutter="gutter">
<form-items-renderer v-model="modelValue" :options="options" :span="span">
<template v-for="(_, name) in slots" :key="name" #[name]="slotProps">
<slot :name="name" v-bind="slotProps" />
</template>
</form-items-renderer>
</el-row>
</el-form>
</template>
<script setup>
import { useSlots, useTemplateRef } from "vue";
import FormItemsRenderer from "./form_items_renderer.vue";
import { ElForm, ElRow } from "element-plus";
import "element-plus/es/components/form/style/css";
import "element-plus/es/components/row/style/css";
const slots = useSlots();
defineOptions({
name: "AppFormBuilder",
});
defineProps({
options: { type: Array, required: true },
rules: { type: Object, default: () => ({}) },
gutter: { type: Number, default: 24 },
span: { type: Number, default: 0 },
labelWidth: { type: [Number, String], default: "120px" },
labelPosition: { type: String, default: "right" },
autoGenerateRequired: { type: Boolean, default: true },
});
const modelValue = defineModel({ type: Object, required: true });
const formRef = useTemplateRef("formRef");
defineExpose({
validate: (callback) => formRef.value.validate(callback),
resetFields: () => formRef.value.resetFields(),
});
</script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,73 @@
<template>
<el-dialog v-model="visible" :title="title" :before-close="fnClose">
<el-form ref="formRef" :model="form" :rules="rules" :label-width="labelWidth">
<slot :form="form"></slot>
<el-form-item label="附件" prop="file">
<app-upload v-model="form.file" accept=".xls,.xlsx" />
</el-form-item>
</el-form>
<template #footer>
<el-button v-if="templateUrl" @click="fnExportTemplates">
导出模板
</el-button>
<el-button type="primary" @click="fnSubmit"></el-button>
<el-button @click="fnClose"></el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref } from "vue";
import { debounce } from "throttle-debounce";
import { useVModel } from "@vueuse/core";
import { ElDialog, ElForm, ElFormItem, ElButton, ElMessageBox } from "element-plus";
import "element-plus/es/components/dialog/style/css";
import "element-plus/es/components/form/style/css";
import "element-plus/es/components/form-item/style/css";
import "element-plus/es/components/button/style/css";
import AppUpload from "../upload/index.vue";
import useForm from "../../hooks/useForm/index.js";
import { getFileUrl } from "../../utils/index.js";
defineOptions({
name: "AppImportFile",
});
const props = defineProps({
labelWidth: { type: String, default: "80px" },
title: { type: String, default: "导入" },
templateUrl: { type: String, default: "" },
customForm: { type: Boolean, default: false },
form: { type: Object, default: () => ({ file: [] }) },
rules: {
type: Object,
default: () => ({
file: [{ required: true, message: "附件不能为空", trigger: "change" }],
}),
},
});
const emits = defineEmits(["submit", "update:form"]);
const visible = defineModel("visible", { type: Boolean, required: true });
const form = props.customForm
? useVModel(props, "form", emits)
: ref({ file: [] });
const { formRef, validate, reset } = useForm();
const fnExportTemplates = async () => {
await ElMessageBox.confirm("确定要下载excel模板吗", { type: "warning" });
window.open(getFileUrl() + props.templateUrl);
};
const fnClose = () => {
reset();
visible.value = false;
};
const fnSubmit = debounce(
1000,
async () => {
await validate();
emits("submit", form.value);
reset();
},
{ atBegin: true }
);
</script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,29 @@
<template>
<el-descriptions :column="column" border>
<template v-for="item in options" :key="item.key">
<el-descriptions-item v-if="!item.hidden" :label="item.label" :span="item.span || 1">
<slot :name="item.key">
<template v-if="item.value">{{ item.value }}</template>
<template v-if="!item.value">{{ info[item.key] }}</template>
</slot>
</el-descriptions-item>
</template>
</el-descriptions>
</template>
<script setup>
import { ElDescriptions, ElDescriptionsItem } from "element-plus";
import "element-plus/es/components/descriptions/style/css";
import "element-plus/es/components/descriptions-item/style/css";
defineOptions({
name: "AppInfoBuilder",
});
defineProps({
info: { type: Object, required: true },
options: { type: Array, required: true },
column: { type: Number, default: 2 },
});
</script>
<style scoped lang="scss"></style>

55
components/map/index.vue Normal file
View File

@ -0,0 +1,55 @@
<template>
<el-col :span="12">
<el-form-item label="经度" :prop="longitudeProps" style="flex: 1">
<el-input :value="longitude" disabled />
</el-form-item>
</el-col>
<el-col :span="12">
<div style="display: flex">
<el-form-item label="纬度" :prop="latitudeProps" style="flex: 1">
<el-input :value="latitude" disabled />
</el-form-item>
<el-form-item label-width="10px">
<el-button type="primary" @click="mapVisible = true">
点击定位
</el-button>
</el-form-item>
</div>
</el-col>
<app-map-selector
v-model:visible="mapVisible"
v-model:longitude="longitude"
v-model:latitude="latitude"
/>
</template>
<script setup>
import { ref } from "vue";
import AppMapSelector from "./map.vue";
import { ElCol, ElFormItem, ElInput, ElButton } from "element-plus";
import "element-plus/es/components/col/style/css";
import "element-plus/es/components/form-item/style/css";
import "element-plus/es/components/input/style/css";
import "element-plus/es/components/button/style/css";
defineOptions({
name: "AppMap",
});
defineProps({
longitudeProps: { type: String, default: "longitude" },
latitudeProps: { type: String, default: "latitude" },
});
const longitude = defineModel("longitude", {
type: [Number, String],
required: true,
default: "",
});
const latitude = defineModel("latitude", {
type: [Number, String],
required: true,
default: "",
});
const mapVisible = ref(false);
</script>
<style scoped lang="scss"></style>

141
components/map/map.vue Normal file
View File

@ -0,0 +1,141 @@
<template>
<el-dialog v-model="visible" title="坐标">
<el-form label-position="right" label-width="100px">
<el-row>
<el-col :span="12">
<el-form-item label="关键字搜索">
<el-input v-model="localSearch" clearable />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label-width="10px">
<el-button type="primary" @click="fnLocalSearch"></el-button>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="坐标:">
<el-input disabled :model-value="currentLongitude || longitude" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label-width="10px">
<el-input disabled :model-value="currentLatitude || latitude" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<div
v-loading="loading"
element-loading-text="地图正在加载中..."
element-loading-background="rgba(0, 0, 0, 0.5)"
>
<div id="map_container" style="width: 100%; height: 500px" />
</div>
<template #footer>
<el-button @click="fnClose"></el-button>
<el-button type="primary" @click="fnConfirm"> </el-button>
</template>
</el-dialog>
</template>
<script setup>
import { nextTick, ref, watch } from "vue";
import { ElDialog, ElForm, ElRow, ElCol, ElFormItem, ElInput, ElButton } from "element-plus";
import "element-plus/es/components/dialog/style/css";
import "element-plus/es/components/form/style/css";
import "element-plus/es/components/row/style/css";
import "element-plus/es/components/col/style/css";
import "element-plus/es/components/form-item/style/css";
import "element-plus/es/components/input/style/css";
import "element-plus/es/components/button/style/css";
defineOptions({
name: "AppMapSelector",
});
let mapInstance;
const visible = defineModel("visible", { type: Boolean, required: true });
const longitude = defineModel("longitude", {
type: [Number, String],
required: true,
default: "",
});
const latitude = defineModel("latitude", {
type: [Number, String],
required: true,
default: "",
});
const loading = ref(false);
const currentLongitude = ref("");
const currentLatitude = ref("");
const localSearch = ref("");
const emits = defineEmits(["submit"]);
const fnMapInit = async () => {
loading.value = true;
await nextTick();
mapInstance = new window.BMapGL.Map("map_container");
mapInstance.centerAndZoom(
new window.BMapGL.Point(
longitude.value || "119.69457721306945",
latitude.value || "39.940504336846665"
),
16
);
mapInstance.enableScrollWheelZoom(true);
loading.value = false;
if (longitude.value && latitude.value) {
const point = new window.BMapGL.Point(longitude.value, latitude.value);
const marker = new window.BMapGL.Marker(point);
mapInstance.addOverlay(marker);
}
mapInstance.addEventListener("click", function (event) {
mapInstance.clearOverlays();
const point = new window.BMapGL.Point(event.latlng.lng, event.latlng.lat);
const marker = new window.BMapGL.Marker(point);
mapInstance.addOverlay(marker);
currentLatitude.value = event.latlng.lat;
currentLongitude.value = event.latlng.lng;
});
};
const fnLocalSearch = () => {
if (localSearch.value) {
const local = new window.BMapGL.LocalSearch(mapInstance, {
renderOptions: { map: mapInstance },
});
local.search(localSearch.value);
}
};
const fnClose = () => {
if (mapInstance) {
mapInstance.destroy();
mapInstance = null;
}
currentLatitude.value = "";
currentLongitude.value = "";
localSearch.value = "";
visible.value = false;
};
const fnConfirm = () => {
latitude.value = currentLatitude.value;
longitude.value = currentLongitude.value;
emits("submit", {
latitude: latitude.value,
longitude: longitude.value,
});
fnClose();
};
watch(
() => visible.value,
(val) => {
if (val && !mapInstance) {
fnMapInit();
}
},
{
immediate: true,
}
);
</script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,40 @@
<template>
<el-pagination
size="small"
:current-page="pagination.currentPage || 1"
:page-size="pagination.pageSize || 10"
layout="total, sizes, prev, pager, next, jumper"
:total="pagination.total || 0"
@update:current-page="handleCurrentChange"
@update:page-size="handleSizeChange"
/>
</template>
<script setup>
import { ElPagination } from "element-plus";
import "element-plus/es/components/pagination/style/css";
defineOptions({
name: "AppPagination",
});
const emits = defineEmits(["get-data"]);
const pagination = defineModel("pagination", { type: Object, required: true });
const handleCurrentChange = (val) => {
pagination.value = {
currentPage: val,
pageSize: pagination.value.pageSize,
total: pagination.value.total,
};
emits("get-data");
};
const handleSizeChange = (val) => {
pagination.value = {
currentPage: 1,
pageSize: val,
total: pagination.value.total,
};
emits("get-data");
};
</script>
<style scoped lang="scss"></style>

65
components/pdf/index.vue Normal file
View File

@ -0,0 +1,65 @@
<template>
<el-dialog
title="文档"
:model-value="visible && model === 'dialog'"
:append-to-body="appendToBody"
@update:model-value="visible = false"
>
<div v-if="visible" style="height: 690px; overflow-y: auto">
<vue-office-pdf :src="fnSrc(props.src)" style="height: 100%" @error="error" @rendered="rendered" />
</div>
<template #footer>
<el-button type="primary" @click="useDownloadFile(fnSrc(props.src))">
下载
</el-button>
</template>
</el-dialog>
<div v-if="model === 'normal'" :key="src" style="height: 690px; overflow-y: auto">
<vue-office-pdf :src="fnSrc(props.src)" style="height: 100%" @error="error" @rendered="rendered" />
</div>
</template>
<script setup>
import VueOfficePdf from '@vue-office/pdf'
import { ElDialog, ElButton, ElMessage } from "element-plus";
import "element-plus/es/components/dialog/style/css";
import "element-plus/es/components/button/style/css";
import { getFileUrl } from "../../utils/index.js";
import useDownloadFile from "../../hooks/useDownloadFile/index.js";
const VITE_FILE_URL = getFileUrl();
defineOptions({
name: "AppPdf",
});
const props = defineProps({
src: { type: String, required: true },
model: {
type: String,
validator: (value) => {
const typeList = ["dialog", "normal"];
if (typeList.includes(value)) {
return true;
} else {
throw new Error(`model必须是${typeList.join("、")}之一`);
}
},
default: "dialog",
},
appendToBody: { type: Boolean, default: false },
});
const visible = defineModel("visible", { type: Boolean, default: false });
const emits = defineEmits(['rendered', 'error']);
const fnSrc = (src) => {
if (!src) return;
if (src.indexOf("http") !== -1 || src.indexOf("https") !== -1) return src;
else return VITE_FILE_URL + src;
};
const rendered = () => {
emits('rendered');
}
const error = () => {
visible.value = false;
ElMessage.error("文件加载失败");
emits('error');
}
</script>

View File

@ -0,0 +1,28 @@
<template>
<div>
<img
v-for="(item, index) in imgs?.filter(Boolean)"
:key="index"
v-viewer
:src="item.filePath ? fileUrl + item.filePath : fileUrl + item"
class="ml-10"
width="100"
height="100"
alt=""
/>
</div>
</template>
<script setup>
import { getFileUrl } from "../../utils/index.js";
defineOptions({
name: "AppPreviewImg",
});
defineProps({
imgs: { type: Array, required: true },
});
const fileUrl = getFileUrl();
</script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,45 @@
<template>
<div v-if="file.length === 0 && name && url">
{{ name }}
<el-button type="primary" size="small" class="ml-5" @click="fnPreviewPdf(url)">
预览
</el-button>
</div>
<template v-if="file.length > 0 && !name && !url">
<div v-for="(item, index) in file" :key="index" class="mt-5">
{{ item.name || item.fileName || item[nameKey] }}
<el-button type="primary" size="small" class="ml-10" @click="fnPreviewPdf(item.filePath || item[urlKey])">
预览
</el-button>
</div>
</template>
<app-pdf v-model:visible="pdfDialog.visible" :src="pdfDialog.src" />
</template>
<script setup>
import { ref } from "vue";
import { ElButton } from "element-plus";
import "element-plus/es/components/button/style/css";
import AppPdf from "../pdf/index.vue";
defineOptions({
name: "AppPreviewPdf",
});
defineProps({
file: { type: Array, default: () => [] },
nameKey: { type: String, default: "" },
urlKey: { type: String, default: "" },
name: { type: String, default: "" },
url: { type: String, default: "" },
});
const pdfDialog = ref({
visible: false,
src: "",
});
const fnPreviewPdf = (src) => {
pdfDialog.value.visible = true;
pdfDialog.value.src = src;
};
</script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,57 @@
<template>
<el-dialog :title="title" :model-value="visible && model === 'dialog'" @update:model-value="visible = false">
<slot></slot>
<div class="tc mt-20 mb-20">
<img :src="src" alt="" width="200" height="200" />
</div>
<template #footer>
<slot name="footer"></slot>
</template>
</el-dialog>
<div v-if="model === 'normal'">
<div class="tc mt-20 mb-20">
<img :src="src" alt="" width="200" height="200" />
</div>
</div>
</template>
<script setup>
import { useQRCode } from "@vueuse/integrations/useQRCode";
import { ElDialog } from "element-plus";
import "element-plus/es/components/dialog/style/css";
defineOptions({
name: "AppQrCode",
});
const props = defineProps({
src: { type: String, required: true, default: "" },
title: { type: String, default: "二维码" },
model: {
type: String,
validator: (value) => {
const typeList = ["dialog", "normal"];
if (typeList.includes(value)) {
return true;
} else {
throw new Error(`model必须是${typeList.join("、")}之一`);
}
},
default: "dialog",
},
});
const visible = defineModel("visible", {
type: Boolean,
required: false,
default: false,
});
const src = useQRCode(() => props.src, {
width: 200,
height: 200,
margin: 1,
correctLevel: "H",
});
</script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,84 @@
<template>
<el-form :model="modelValue" :label-width="labelWidth" @submit.prevent="emits('submit')">
<el-row :class="className">
<template v-if="options && options.length">
<form-items-renderer v-model="modelValue" :options="options" :span="6" :collapse="collapse" :auto-generate-required="false">
<template v-for="(_, name) in slots" :key="name" #[name]="slotProps">
<slot :name="name" v-bind="slotProps" />
</template>
</form-items-renderer>
</template>
<template v-else>
<slot :collapse="collapse"></slot>
</template>
<el-col :span="showCollapseButton ? (collapse ? 6 : span) : span">
<el-form-item label-width="10px" class="end">
<el-button type="primary" native-type="submit"> 搜索 </el-button>
<el-button native-type="reset" @click="emits('submit')">
重置
</el-button>
<template v-if="showCollapseButton">
<el-button v-if="collapse" :icon="ArrowDown" link text type="primary" @click="changeSearchCollapse">
展开
</el-button>
<el-button v-if="!collapse" :icon="ArrowUp" link text type="primary" @click="changeSearchCollapse">
收起
</el-button>
</template>
</el-form-item>
</el-col>
<el-col v-if="slots.button" :span="24">
<el-form-item label-width="0">
<slot name="button"></slot>
</el-form-item>
</el-col>
</el-row>
</el-form>
</template>
<script setup>
import { nextTick, onMounted, ref, useSlots, watch } from "vue";
import { uniqueId } from "lodash-es";
import { ArrowDown, ArrowUp } from "@element-plus/icons-vue";
import { ElForm, ElRow, ElCol, ElFormItem, ElButton } from "element-plus";
import "element-plus/es/components/form/style/css";
import "element-plus/es/components/row/style/css";
import "element-plus/es/components/col/style/css";
import "element-plus/es/components/form-item/style/css";
import "element-plus/es/components/button/style/css";
import FormItemsRenderer from "../form_builder/form_items_renderer.vue";
const slots = useSlots();
defineOptions({
name: "AppSearch",
});
const props = defineProps({
labelWidth: { type: String, default: "110px" },
options: { type: Array, default: () => [] },
});
const modelValue = defineModel({ type: Object, required: true });
const emits = defineEmits(["submit"]);
const className = ref(uniqueId("_"));
const span = ref(6);
const showCollapseButton = ref(false);
const collapse = ref(true);
const changeSearchCollapse = () => {
collapse.value = !collapse.value;
};
onMounted(() => {
watch(
() => props.options,
async () => {
await nextTick();
const colEl = document.querySelectorAll(`.${className.value} .el-col`);
const colElLength = colEl.length;
const excludeLast = colElLength - (slots.button ? 2 : 1);
span.value = { 0: 24, 1: 18, 2: 12, 3: 6 }[excludeLast % 4];
showCollapseButton.value = excludeLast > 3;
},
{ immediate: true }
);
});
</script>
<style scoped lang="scss"></style>

90
components/sign/index.vue Normal file
View File

@ -0,0 +1,90 @@
<template>
<div>
<div>
<el-button type="primary" @click="fnOpen"></el-button>
</div>
<div v-if="modelValue" style="border: 1px dashed #ccc" class="mt-10">
<img :src="modelValue" alt="" style="width: 100%" />
</div>
</div>
<el-dialog v-model="visible" title="签字">
<vue-esign
ref="signRef"
:width="800"
:height="300"
:is-crop="false"
:is-clear-bg-color="false"
:line-width="6"
line-color="#000"
bg-color="#fff"
/>
<template #footer>
<el-button @click="fnReset"></el-button>
<el-button type="primary" @click="fnGenerate"></el-button>
<el-button @click="fnClose"></el-button>
</template>
</el-dialog>
</template>
<script setup>
import { useTemplateRef } from "vue";
import { ElDialog, ElButton, ElMessage } from "element-plus";
import "element-plus/es/components/dialog/style/css";
import "element-plus/es/components/button/style/css";
import VueEsign from "vue-esign";
defineOptions({
name: "AppSign",
});
const emits = defineEmits(["confirm"]);
const modelValue = defineModel({ type: String, default: "" });
const visible = defineModel("visible", { type: Boolean, default: false });
const signRef = useTemplateRef("signRef");
const fnOpen = () => {
visible.value = true;
};
const fnReset = () => {
signRef.value.reset();
};
const fnGenerate = () => {
signRef.value
.generate()
.then((res) => {
modelValue.value = res;
emits("confirm", res);
fnClose();
})
.catch(() => {
ElMessage.warning("请签字!");
});
};
const fnClose = () => {
fnReset();
visible.value = false;
};
</script>
<style scoped lang="scss">
.title {
display: flex;
justify-content: space-between;
div {
flex-basis: 33.333%;
&:nth-child(2) {
text-align: center;
}
&:nth-child(3) {
padding-right: 30px;
text-align: right;
}
}
}
canvas {
border: 1px dashed #7e7d7d;
margin: auto;
}
</style>

135
components/table/index.vue Normal file
View File

@ -0,0 +1,135 @@
<template>
<el-table
ref="tableRef"
size="small"
:data="data"
:border="border"
:stripe="stripe"
:height="height"
:max-height="maxHeight"
:highlight-current-row="highlightCurrentRow"
:row-key="getRowKey"
:row-class-name="rowClassName"
:row-style="rowStyle"
:show-header="showHeader"
:show-summary="showSummary"
:summary-method="summaryMethod"
:span-method="spanMethod"
:default-expand-all="defaultExpandAll"
:tree-props="treeProps"
:header-cell-style="headerCellStyle"
:cell-style="cellStyle"
:show-overflow-tooltip="showOverflowTooltip"
style="width: 100%"
@row-click="rowClick"
@row-dblclick="rowDblclick"
>
<el-table-column
v-if="showSelection"
type="selection"
:selectable="selectable"
reserve-selection
width="60"
:show-overflow-tooltip="false"
/>
<template v-if="showIndex">
<el-table-column v-if="showPagination" label="序号" width="60">
<template #default="{ $index }">
{{ serialNumber(pagination, $index) }}
</template>
</el-table-column>
<el-table-column v-if="!showPagination" label="序号" width="60" type="index" />
</template>
<slot></slot>
</el-table>
<div v-if="showPagination || slots.button" class="table_footer">
<div>
<slot name="button"></slot>
</div>
<app-pagination v-if="showPagination" v-model:pagination="pagination" @get-data="emits('get-data')" />
</div>
</template>
<script setup>
import { useSlots, useTemplateRef } from "vue";
import { ElTable, ElTableColumn } from "element-plus";
import "element-plus/es/components/table/style/css";
import "element-plus/es/components/table-column/style/css";
import AppPagination from "../pagination/index.vue";
import { serialNumber } from "../../utils/index.js";
const slots = useSlots();
defineOptions({
name: "AppTable",
});
const props = defineProps({
data: { type: Array, required: true },
showPagination: { type: Boolean, default: true },
showIndex: { type: Boolean, default: true },
showSelection: { type: Boolean, default: false },
stripe: { type: Boolean, default: true },
border: { type: Boolean, default: true },
showHeader: { type: Boolean, default: true },
highlightCurrentRow: { type: Boolean, default: false },
showSummary: { type: Boolean, default: false },
defaultExpandAll: { type: Boolean, default: false },
rowKey: { type: [String, Function] },
maxHeight: { type: [String, Number] },
height: { type: [String, Number] },
rowClassName: { type: Function },
rowStyle: { type: Function },
summaryMethod: { type: Function },
spanMethod: { type: Function },
selectable: { type: Function },
treeProps: {
type: Object,
default: () => ({ hasChildren: "hasChildren", children: "children" }),
},
headerCellStyle: { type: Object, default: () => ({}) },
cellStyle: { type: [Object, Function], default: () => ({}) },
showOverflowTooltip: { type: Boolean, default: true },
});
const pagination = defineModel("pagination", {
type: Object,
default: () => ({
currentPage: 1,
pageSize: 10,
total: 0,
}),
});
const emits = defineEmits(["get-data", "row-click", "row-dblclick"]);
const tableRef = useTemplateRef("tableRef");
const getRowKey = (row) => {
if (!props.rowKey) return;
if (typeof props.rowKey === "string") return row[props.rowKey];
else return props.rowKey(row);
};
const rowClick = (row, column, event) => {
emits("row-click", row, column, event);
};
const rowDblclick = (row, column, event) => {
emits("row-dblclick", row, column, event);
};
const getSelectionRows = () => {
return tableRef.value.getSelectionRows();
};
const clearSelection = () => {
return tableRef.value.clearSelection();
};
const toggleRowSelection = (value, selected = true) => {
tableRef.value.toggleRowSelection(value, selected);
};
defineExpose({
getSelectionRows,
clearSelection,
toggleRowSelection,
});
</script>
<style lang="scss" scoped>
.table_footer {
margin-top: 20px;
display: flex;
justify-content: space-between;
}
</style>

View File

@ -0,0 +1,39 @@
<template>
<el-tooltip placement="top" :persistent="!!imgs.length">
<template #content>
<template v-if="imgs && imgs.length > 0">
<img
v-for="(item, index) in imgs"
:key="item.imgFilesId || index"
v-viewer
:src="
item.filePath ? VITE_FILE_URL + item.filePath : VITE_FILE_URL + item
"
width="100"
height="100"
alt=""
class="ml-10"
/>
</template>
<span v-else></span>
</template>
<el-tag>预览</el-tag>
</el-tooltip>
</template>
<script setup>
import { ElTooltip, ElTag } from "element-plus";
import "element-plus/es/components/tooltip/style/css";
import "element-plus/es/components/tag/style/css";
import { getFileUrl } from "../../utils/index.js";
defineOptions({
name: "AppTooltipImg",
});
const VITE_FILE_URL = getFileUrl();
defineProps({
imgs: { type: Array, required: true },
});
</script>
<style scoped lang="scss"></style>

28
components/txt/index.vue Normal file
View File

@ -0,0 +1,28 @@
<template>
<el-dialog v-model="visible" title="文本文档" :append-to-body="appendToBody">
<el-input autosize :model-value="value" readonly type="textarea" />
</el-dialog>
</template>
<script setup>
import { ref, watchEffect } from "vue";
import { ElDialog, ElInput } from "element-plus";
import "element-plus/es/components/dialog/style/css";
import "element-plus/es/components/input/style/css";
import { readTxtDocument } from "../../utils/index.js";
defineOptions({
name: "AppTxt",
});
const props = defineProps({
src: { type: String, required: true, default: "" },
appendToBody: { type: Boolean, default: false },
});
const value = ref("");
const visible = defineModel("visible", { type: Boolean, required: true });
watchEffect(async () => {
if (props.src) value.value = await readTxtDocument(props.src);
});
</script>
<style lang="scss" scoped></style>

150
components/upload/index.vue Normal file
View File

@ -0,0 +1,150 @@
<template>
<el-upload
ref="uploadRef"
style="width: 100%"
:file-list="modelValue"
:action="action"
multiple
:limit="limit"
:list-type="listType"
:auto-upload="autoUpload"
:disabled="disabled"
:accept="accept"
:on-remove="onRemove"
:before-remove="beforeRemove"
:on-change="onChange"
:on-exceed="onExceed"
:on-preview="onPreview"
:http-request="httpRequest"
:show-file-list="showFileList"
:class="{ hide: modelValue.length === limit }"
>
<el-icon v-if="listType === 'picture-card'" size="32"><plus /></el-icon>
<el-button v-else type="primary">点击选择文件上传</el-button>
<template #tip>
<div class="mt-10 text-red">
<slot name="tip">
<div>{{ tip.join("") }}</div>
</slot>
</div>
</template>
</el-upload>
<el-dialog v-model="visible" title="查看图片">
<img :src="imageUrl" alt="Preview Image" style="width: 100%; object-fit: scale-down" />
</el-dialog>
</template>
<script setup>
import { computed, ref, useTemplateRef } from "vue";
import { ElMessage, ElMessageBox, ElUpload, ElIcon, ElButton, ElDialog } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import "element-plus/es/components/upload/style/css";
import "element-plus/es/components/icon/style/css";
import "element-plus/es/components/button/style/css";
import "element-plus/es/components/dialog/style/css";
defineOptions({
name: "AppUpload",
});
const props = defineProps({
autoUpload: { type: Boolean, default: false },
action: { type: String, default: "" },
limit: { type: Number, default: 1 },
listType: { type: String, default: "text" },
accept: { type: String, default: "" },
ratio: { type: String, default: "" },
disabled: { type: Boolean, default: false },
deleteToServer: { type: Boolean, default: false },
showFileList: { type: Boolean, default: true },
httpRequest: { type: Function },
size: { type: Number, default: 0 },
beforeRemove: { type: Function, default: () => {} },
});
const visible = ref(false);
const imageUrl = ref("");
const modelValue = defineModel({ type: Array, required: true });
const deleteFiles = defineModel("deleteFiles", {
type: Array,
default: () => [],
});
const emits = defineEmits(["preview", "delete"]);
const uploadRef = useTemplateRef("uploadRef");
const tip = computed(() =>
[
`最多上传${props.limit}个文件`,
props.accept
? `并且只能上传${props.accept
.replaceAll(".", "")
.split(",")
.join("、")}格式的文件`
: "可以上传任意格式的文件",
props.size ? `文件大小不能超过${props.size}M` : "",
props.ratio ? `只能上传${props.ratio}分辨率的图片` : "",
].filter(Boolean)
);
const onExceed = () => {
ElMessage.warning(`最多上传${props.limit}个文件`);
};
const beforeRemove = async (uploadFile) => {
if (props.deleteToServer && uploadFile.imgFilesId) {
await ElMessageBox.confirm("确定要删除吗?", {
type: "warning",
});
await props.beforeRemove(uploadFile);
}
};
const onRemove = (uploadFile, uploadFiles) => {
modelValue.value = uploadFiles;
deleteFiles.value.push(uploadFile);
emits("delete", uploadFile);
};
const onChange = (uploadFile, uploadFiles) => {
const accept = props.accept && props.accept.split(",");
const ratio = props.ratio && props.ratio.split("*");
const suffix = uploadFile.raw.name.substring(
uploadFile.raw.name.lastIndexOf("."),
uploadFile.raw.name.length
);
const size = props.size * 1024 * 1024;
if (ratio) {
const img = new Image();
img.src = uploadFile.url;
img.onload = () => {
if (img.width !== +ratio[0] && img.height !== +ratio[1]) {
ElMessage.warning(`只能上传${props.ratio}分辨率的图片`);
uploadRef.value.handleRemove(uploadFile.raw);
}
};
}
if (size) {
if (uploadFile.size > size) {
ElMessage.warning(`文件大小不能超过${props.size}M`);
uploadRef.value.handleRemove(uploadFile.raw);
}
}
if (accept) {
if (accept.includes(suffix)) {
modelValue.value = uploadFiles;
} else {
ElMessage.warning(`只能上传${props.accept}格式的文件`);
uploadRef.value.handleRemove(uploadFile.raw);
}
} else {
modelValue.value = uploadFiles;
}
};
const onPreview = (uploadFile) => {
if (props.listType === "picture-card") {
visible.value = true;
imageUrl.value = uploadFile.url;
} else {
emits("preview", uploadFile);
}
};
</script>
<style scoped lang="scss">
.hide :deep(.el-upload--picture-card) {
display: none;
}
</style>

View File

@ -0,0 +1,198 @@
<template>
<div class="mi-captcha">
<div class="mi-captcha-content">
<!-- 没有进行验证-->
<div v-if="!verificationPass" class="mi-captcha-radar" @click="verificationShow = true">
<div class="mi-captcha-radar-ready">
<div class="mi-captcha-radar-ring" />
<div class="mi-captcha-radar-dot" />
</div>
<div class="mi-captcha-radar-tip">点击按钮进行验证</div>
</div>
<!-- 验证通过-->
<div v-if="verificationPass" class="mi-captcha-radar mi-captcha-radar-pass">
<div class="mi-captcha-radar-success mi-captcha-radar-success-icon">
<span role="img" aria-label="verified">
<svg
focusable="false"
class=""
data-icon="verified"
width="1em"
height="1em"
fill="currentColor"
aria-hidden="true"
viewBox="64 64 896 896"
>
<path
d="M447.8 588.8l-7.3-32.5c-.2-1-.6-1.9-1.1-2.7a7.94 7.94 0 00-11.1-2.2L405 567V411c0-4.4-3.6-8-8-8h-81c-4.4 0-8 3.6-8 8v36c0 4.4 3.6 8 8 8h37v192.4a8 8 0 0012.7 6.5l79-56.8c2.6-1.9 3.8-5.1 3.1-8.3zm-56.7-216.6l.2.2c3.2 3 8.3 2.8 11.3-.5l24.1-26.2a8.1 8.1 0 00-.3-11.2l-53.7-52.1a8 8 0 00-11.2.1l-24.7 24.7c-3.1 3.1-3.1 8.2.1 11.3l54.2 53.7z"
/>
<path
d="M866.9 169.9L527.1 54.1C523 52.7 517.5 52 512 52s-11 .7-15.1 2.1L157.1 169.9c-8.3 2.8-15.1 12.4-15.1 21.2v482.4c0 8.8 5.7 20.4 12.6 25.9L499.3 968c3.5 2.7 8 4.1 12.6 4.1s9.2-1.4 12.6-4.1l344.7-268.6c6.9-5.4 12.6-17 12.6-25.9V191.1c.2-8.8-6.6-18.3-14.9-21.2zM810 654.3L512 886.5 214 654.3V226.7l298-101.6 298 101.6v427.6z"
/>
<path
d="M452 297v36c0 4.4 3.6 8 8 8h108v274h-38V405c0-4.4-3.6-8-8-8h-35c-4.4 0-8 3.6-8 8v210h-31c-4.4 0-8 3.6-8 8v37c0 4.4 3.6 8 8 8h244c4.4 0 8-3.6 8-8v-37c0-4.4-3.6-8-8-8h-72V493h58c4.4 0 8-3.6 8-8v-35c0-4.4-3.6-8-8-8h-58V341h63c4.4 0 8-3.6 8-8v-36c0-4.4-3.6-8-8-8H460c-4.4 0-8 3.6-8 8z"
/>
</svg>
</span>
</div>
<div class="mi-captcha-radar-tip">通过验证</div>
</div>
</div>
</div>
<verification :show="verificationShow" @success="verificationSuccess" @close="verificationClose" />
</template>
<script setup>
import Verification from "vue3-puzzle-vcode";
import { ref } from "vue";
defineOptions({
name: "AppVerification",
});
const verificationPass = defineModel("verificationPass", {
type: Boolean,
default: false,
});
const verificationShow = ref(false);
const verificationClose = () => {
verificationShow.value = false;
};
const verificationSuccess = () => {
verificationPass.value = true;
verificationClose();
};
</script>
<style scoped lang="scss">
.mi-captcha {
width: 100%;
flex: 1;
height: 2.625rem;
font-family: "Pingfang SC", "Microsoft YaHei", "Monospaced Number",
"Chinese Quote", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"PingFang SC", "Hiragino Sans GB", "Helvetica Neue", Helvetica, Arial,
sans-serif;
}
.mi-captcha-content {
width: 100%;
height: 100%;
position: relative;
}
.mi-captcha-radar {
display: flex;
align-items: center;
justify-content: flex-start;
flex-direction: row;
height: 100%;
width: 100%;
background-color: #1d1e23;
background-image: linear-gradient(315deg, #a8b4d3 0%, #adc0ed 74%);
border: 1px solid #96a4c8;
cursor: pointer;
min-width: 10rem;
position: relative;
border-radius: 4px;
}
.mi-captcha-radar-ready,
.mi-captcha-radar-success {
display: flex;
align-items: center;
justify-content: center;
flex-direction: row;
flex-wrap: nowrap;
position: relative;
transition: all 0.4s ease;
width: 1.875rem;
height: 1.875rem;
margin: 0.375rem;
}
.mi-captcha-radar-ring,
.mi-captcha-radar-dot {
position: absolute;
border-radius: 50%;
transform: scale(0.4);
width: 100%;
height: 100%;
box-shadow: inset 0 0 0 1px #2c67ec;
background-image: linear-gradient(0, rgba(0, 0, 0, 0) 50%, #fff 50%),
linear-gradient(0, #fff 50%, rgba(0, 0, 0, 0) 50%);
}
.mi-captcha-radar-dot {
background: #2c67ec;
}
.mi-captcha-radar-ring {
animation: mi-anim-wait 1s infinite;
transform: scale(1);
}
.mi-captcha-radar-success {
display: flex;
align-items: center;
justify-content: center;
flex-direction: row;
cursor: default;
}
.mi-captcha-radar-success-icon {
color: #f6ca9d;
animation-name: mi-captcha-success;
animation-timing-function: ease;
animation-iteration-count: 1;
animation-delay: 0.5s;
animation-duration: 0.4s;
font-size: 1.25rem;
}
.mi-captcha-radar-tip {
display: flex;
align-items: center;
justify-content: flex-start;
flex-direction: row;
height: 2.625rem;
padding-left: 0.125rem;
font-size: 0.875rem;
color: #fff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
}
.mi-captcha-radar-pass .mi-captcha-radar-tip {
color: #f6ca9d;
}
@keyframes mi-captcha-success {
25% {
transform: rotate(25deg);
}
100% {
transform: rotate(-360deg);
}
}
@keyframes mi-anim-wait {
60% {
transform: scale(0.75);
}
}
:deep {
.vue-auth-box_ {
background: #2e63d8 !important;
border: 1px solid #2752b3 !important;
}
.mi-captcha-radar-pass .mi-captcha-radar-tip {
color: #ffffff !important;
}
.mi-captcha-radar-success-icon {
color: #ffffff !important;
}
}
</style>

View File

@ -0,0 +1,122 @@
<template>
<div class="s-canvas">
<canvas id="s-canvas" :width="contentWidth" :height="contentHeight" />
</div>
</template>
<script setup>
import { onMounted, watch } from "vue";
defineOptions({
name: "AppVerificationCode",
});
const props = defineProps({
identifyCode: { type: String, default: "1234" },
fontSizeMin: { type: Number, default: 25 },
fontSizeMax: { type: Number, default: 30 },
backgroundColorMin: { type: Number, default: 255 },
backgroundColorMax: { type: Number, default: 255 },
colorMin: { type: Number, default: 0 },
colorMax: { type: Number, default: 160 },
lineColorMin: { type: Number, default: 100 },
lineColorMax: { type: Number, default: 255 },
dotColorMin: { type: Number, default: 0 },
dotColorMax: { type: Number, default: 255 },
contentWidth: { type: Number, default: 112 },
contentHeight: { type: Number, default: 31 },
});
//
const randomNum = (min, max) => {
return Math.floor(Math.random() * (max - min) + min);
};
//
const randomColor = (min, max) => {
const r = randomNum(min, max);
const g = randomNum(min, max);
const b = randomNum(min, max);
return "rgb(" + r + "," + g + "," + b + ")";
};
const drawPic = () => {
const canvas = document.getElementById("s-canvas");
const ctx = canvas.getContext("2d");
ctx.textBaseline = "bottom";
//
ctx.fillStyle = randomColor(
props.backgroundColorMin,
props.backgroundColorMax
);
ctx.fillRect(0, 0, props.contentWidth, props.contentHeight);
//
for (let i = 0; i < props.identifyCode.length; i++) {
drawText(ctx, props.identifyCode[i], i);
}
drawLine(ctx);
drawDot(ctx);
};
const drawText = (ctx, txt, i) => {
ctx.fillStyle = randomColor(props.colorMin, props.colorMax);
ctx.font = randomNum(props.fontSizeMin, props.fontSizeMax) + "px SimHei";
const x = (i + 1) * (props.contentWidth / (props.identifyCode.length + 1));
const y = randomNum(props.fontSizeMax, props.contentHeight - 5);
const deg = randomNum(-45, 45);
//
ctx.translate(x, y);
ctx.rotate((deg * Math.PI) / 180);
ctx.fillText(txt, 0, 0);
//
ctx.rotate((-deg * Math.PI) / 180);
ctx.translate(-x, -y);
};
const drawLine = (ctx) => {
// 线
for (let i = 0; i < 5; i++) {
ctx.strokeStyle = randomColor(props.lineColorMin, props.lineColorMax);
ctx.beginPath();
ctx.moveTo(
randomNum(0, props.contentWidth),
randomNum(0, props.contentHeight)
);
ctx.lineTo(
randomNum(0, props.contentWidth),
randomNum(0, props.contentHeight)
);
ctx.stroke();
}
};
const drawDot = (ctx) => {
//
for (let i = 0; i < 80; i++) {
ctx.fillStyle = randomColor(0, 255);
ctx.beginPath();
ctx.arc(
randomNum(0, props.contentWidth),
randomNum(0, props.contentHeight),
1,
0,
2 * Math.PI
);
ctx.fill();
}
};
watch(
() => props.identifyCode,
() => {
drawPic();
}
);
onMounted(() => {
drawPic();
});
</script>
<style scoped lang="scss">
.s-canvas {
height: 38px;
padding-top: 3px;
canvas {
margin-top: 1px;
margin-right: 10px;
}
}
</style>

View File

@ -0,0 +1,83 @@
<template>
<div>
<el-dialog v-if="isDialog" v-model="visible" title="视频" :append-to-body="appendToBody">
<app-ali-player
ref="playerRef"
:source="fnSrc(src)"
:vid="vid"
:play-auth="playAuth"
:visible="visible"
:cover="cover"
:autoplay="autoplay"
:show-progress="showProgress"
:play-time="playTime"
:is-live="isLive"
:width="width"
:height="height"
/>
</el-dialog>
<app-ali-player
v-else
ref="playerRef"
:source="fnSrc(src)"
:vid="vid"
:play-auth="playAuth"
:visible="visible"
:cover="cover"
:autoplay="autoplay"
:show-progress="showProgress"
:play-time="playTime"
:is-live="isLive"
:width="width"
:height="height"
/>
</div>
</template>
<script setup>
import { useTemplateRef, watchEffect } from "vue";
import { ElDialog } from "element-plus";
import "element-plus/es/components/dialog/style/css";
import AppAliPlayer from "../ali-player/index.vue";
import { getFileUrl } from "../../utils/index.js";
defineOptions({
name: "AppVideo",
});
const props = defineProps({
src: { type: String, default: "" },
vid: { type: String, default: "" },
playAuth: { type: String, default: "" },
appendToBody: { type: Boolean, default: false },
cover: { type: String, default: "" },
autoplay: { type: Boolean, default: true },
showProgress: { type: Boolean, default: true },
playTime: { type: Number, default: 0 },
isDialog: { type: Boolean, default: true },
isLive: { type: Boolean, default: false },
width: { type: String, default: "100%" },
height: { type: String, default: "600px" },
});
const visible = defineModel("visible", {
type: Boolean,
required: false,
default: false,
});
const playerRef = useTemplateRef("playerRef");
const fnSrc = (src) => {
if (!src) return;
if (src.indexOf("http") !== -1 || src.indexOf("https") !== -1) return src;
else return getFileUrl() + src;
};
watchEffect(() => {
if (props.isDialog) {
if (visible.value) {
playerRef.value && playerRef.value.play();
} else {
playerRef.value && playerRef.value.pause();
}
}
});
</script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,69 @@
<template>
<el-input v-model="filterText" placeholder="输入关键字进行过滤" class="mb-10" />
<el-tree
ref="treeRef"
:props="prop"
:node-key="nodeKey"
accordion
:data="data"
:filter-node-method="fnFilterNode"
:default-expanded-keys="[defaultExpandedKeys]"
@node-click="nodeClick"
/>
</template>
<script setup>
import { ref, watch, watchEffect } from "vue";
import { ElInput, ElTree } from "element-plus";
import "element-plus/es/components/input/style/css";
import "element-plus/es/components/tree/style/css";
defineOptions({
name: "AppViewTree",
});
const props = defineProps({
prop: {
type: Object,
default: () => ({ children: "children", label: "name" }),
},
nodeKey: { type: String, default: "id" },
defaultExpandedKeys: { type: String, default: "" },
api: { type: Function, required: true },
});
const emits = defineEmits(["node-click", "throw-data"]);
const refresh = defineModel("refresh", { type: Boolean, default: false });
const treeRef = ref(null);
const filterText = ref("");
const data = ref([]);
watch(filterText, (val) => {
treeRef.value.filter(val);
});
watchEffect(() => {
if (refresh.value) fnGetTreeData();
});
const fnFilterNode = (value, data) => {
if (!value) return true;
return data.name.includes(value);
};
const fnGetTreeData = async () => {
const value = await props.api();
fnAddLevel(value);
data.value = value;
emits("throw-data", value);
refresh.value = false;
};
fnGetTreeData();
const fnAddLevel = (value, level = 1) => {
value.forEach((item) => {
item.level = level;
if (item.children && item.children.length) {
fnAddLevel(item.children, level + 1);
}
});
};
const nodeClick = (data) => {
emits("node-click", data);
};
</script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,15 @@
export function conversionRouterMeta(menuList) {
for (let i = 0; i < menuList.length; i++) {
menuList[i].meta = JSON.parse(menuList[i].meta);
if (menuList[i].list.length > 0) conversionRouterMeta(menuList[i].list);
}
return menuList;
}
export function conversionNavMeta(navList) {
const navTempList = [];
for (let i = 0; i < navList.length; i++) {
const item = { title: navList[i].name, model: navList[i].bianma };
navTempList.push(item);
}
return navTempList;
}

269
css/common.scss Normal file
View File

@ -0,0 +1,269 @@
// 5
// 使1使flexmin-width: 0;
@for $i from 1 through 5 {
.line-#{$i} {
@if $i == 1 {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
} @else {
display: -webkit-box !important;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-all;
-webkit-line-clamp: $i;
-webkit-box-orient: vertical !important;
}
}
}
// 1-50marginpadding
@for $i from 1 through 50 {
.m-#{$i} {
margin: #{$i}px;
}
.mt-#{$i} {
margin-top: #{$i}px;
}
.mr-#{$i} {
margin-right: #{$i}px;
}
.mb-#{$i} {
margin-bottom: #{$i}px;
}
.ml-#{$i} {
margin-left: #{$i}px;
}
.mtb-#{$i} {
margin-top: #{$i}px;
margin-bottom: #{$i}px;
}
.mlr-#{$i} {
margin-left: #{$i}px;
margin-right: #{$i}px;
}
.p-#{$i} {
padding: #{$i}px;
}
.pt-#{$i} {
padding-top: #{$i}px;
}
.pr-#{$i} {
padding-right: #{$i}px;
}
.pb-#{$i} {
padding-bottom: #{$i}px;
}
.pl-#{$i} {
padding-left: #{$i}px;
}
.ptb-#{$i} {
padding-top: #{$i}px;
padding-bottom: #{$i}px;
}
.plr-#{$i} {
padding-left: #{$i}px;
padding-right: #{$i}px;
}
.m--#{$i} {
margin: -#{$i}px;
}
.mt--#{$i} {
margin-top: -#{$i}px;
}
.mr--#{$i} {
margin-right: -#{$i}px;
}
.mb--#{$i} {
margin-bottom: -#{$i}px;
}
.ml--#{$i} {
margin-left: -#{$i}px;
}
.mtb--#{$i} {
margin-top: -#{$i}px;
margin-bottom: -#{$i}px;
}
.mlr--#{$i} {
margin-left: -#{$i}px;
margin-right: -#{$i}px;
}
.p--#{$i} {
padding: -#{$i}px;
}
.pt--#{$i} {
padding-top: -#{$i}px;
}
.pr--#{$i} {
padding-right: -#{$i}px;
}
.pb--#{$i} {
padding-bottom: -#{$i}px;
}
.pl--#{$i} {
padding-left: -#{$i}px;
}
.ptb--#{$i} {
padding-top: -#{$i}px;
padding-bottom: -#{$i}px;
}
.plr--#{$i} {
padding-left: -#{$i}px;
padding-right: -#{$i}px;
}
}
* {
box-sizing: border-box;
font-size: 14px;
&:not(dd,dl,dt,h1, h2, h3, h4, h5, h6) {
margin: 0;
padding: 0;
}
}
h1, h2, h3, h4, h5, h6 {
font-size: revert;
}
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
input:-webkit-autofill:active {
-webkit-transition-delay: 99999s;
-webkit-transition: color 99999s ease-out, background-color 99999s ease-out;
}
#app {
background-color: #f6f8f9;
min-height: 100vh;
}
a {
text-decoration: none;
color: var(--el-color-primary);
}
.end {
.el-form-item__content {
justify-content: end;
}
}
.text-blue {
color: #0000ff;
}
.text-yellow {
color: #bebe05;
}
.text-orange {
color: #de9004;
}
.text-red {
color: #ff0000;
}
.text-green {
color: #0bb20c;
}
.tc {
text-align: center;
}
.tr {
text-align: right;
}
.tl {
text-align: left;
}
.dn {
display: none;
}
.table {
border-collapse: collapse;
width: 100%;
td,
th {
border: 1px solid #eaeaea;
padding: 8px;
line-height: 1.6;
text-align: center;
}
.title {
background-color: #f9f9f9;
}
}
img.ml-10 {
&:first-child {
margin-left: 0;
}
}
.print_use {
display: none;
}
.viewer-zoom-in, .viewer-zoom-out, .viewer-one-to-one, .viewer-reset, .viewer-prev, .viewer-play, .viewer-next, .viewer-rotate-left, .viewer-rotate-right, .viewer-flip-horizontal, .viewer-flip-vertical, .viewer-fullscreen, .viewer-fullscreen-exit, .viewer-close {
&::before {
font-size: unset !important;
}
}
.vue-pdf__wrapper-annotation-layer {
height: 0 !important;
}
.w-e-bar {
border: 1px solid var(--el-border-color);
border-bottom: none;
}
.w-e-text-container {
border: 1px solid var(--el-border-color);
}
.w-e-bar-divider {
display: none !important;
}
//
@page {
size: auto;
margin: 3mm;
}
@media print {
.print_use {
border-collapse: collapse;
width: 100%;
display: table;
td, th {
border: 1px solid #eaeaea;
padding: 8px;
line-height: 1.6;
text-align: center;
}
}
.print_no_use {
display: none;
}
}
.iframe {
position: relative;
border: none;
}

157
css/element.scss Normal file
View File

@ -0,0 +1,157 @@
.el-select, .el-cascader, .el-date-editor.el-input, .el-date-editor.el-input__wrapper, .el-input__wrapper, .el-input-number, .el-select-v2 {
width: 100% !important;
}
.el-pagination .el-select {
width: 128px !important;
}
.el-pagination--small .el-select {
width: 100px !important;
}
.el-table .el-table__cell {
text-align: left;
}
.el-descriptions__label {
width: 200px;
}
.el-descriptions__content {
width: auto;
}
.el-divider__text {
font-size: 16px !important;
font-weight: 700 !important;
}
.el-form-item__label {
font-weight: 700;
}
.el-dialog {
--el-dialog-margin-top: 50px !important;
padding: 0 !important;
.el-dialog__header {
border-bottom: 1px solid #f1f1f1;
padding-left: 20px;
height: 48px;
line-height: 48px;
}
.el-dialog__body {
padding: 16px 20px;
}
.el-dialog__footer {
border-top: 1px solid #f1f1f1;
padding: 16px 20px;
}
}
.el-table {
* {
font-size: 12px;
}
th.el-table__cell {
--el-table-header-bg-color: rgb(245, 247, 250);
font-weight: bold;
color: rgb(0, 0, 0);
}
.el-table__cell {
padding: 8px 0 !important;
text-align: center !important;
}
}
.el-page-header {
border-bottom: 1px solid #eaeaea;
padding: 0 20px 20px 20px;
margin: 0 -20px 20px -20px;
.el-page-header__content {
font-size: 17px;
}
}
.el-form-item__label {
font-weight: normal;
font-size: 13px;
}
.el-button > span {
font-size: 12px;
}
.el-input__inner {
font-size: 13px !important;
}
.el-table-v2 {
* {
font-size: 12px;
}
--el-text-color-secondary: rgb(0, 0, 0);
.el-table-v2__row {
color: #606266;
}
.el-table-v2__row-cell, .el-table-v2__header-cell {
justify-content: center;
text-align: center;
}
.el-table-v2__row {
&:nth-child(even) {
background: var(--el-fill-color-lighter);
}
}
background: var(--el-table-row-hover-bg-color);
border: var(--el-table-border);
.el-table-v2__header-wrapper {
border-right: 1px var(--el-table-border-color) solid;
background: var(--el-table-row-hover-bg-color);
}
.el-table-v2__header {
background: var(--el-table-row-hover-bg-color);
}
.el-table-v2__header-cell {
background: var(--el-table-row-hover-bg-color);
border-right: 1px var(--el-table-border-color) solid;
}
.el-table-v2__row-cell {
border-right: 1px var(--el-table-border-color) solid;
}
.el-vl__wrapper.el-table-v2__body {
border-right: 1px var(--el-table-border-color) solid;
}
.el-table-v2__header-cell:last-child {
border-right: 0;
}
.el-table-v2__row-cell:last-child {
border-right: 0;
}
}
.el-pagination .el-select .el-select__clear {
display: none !important;
}
.el-icon svg {
font-size: unset;
}

3
css/index.scss Normal file
View File

@ -0,0 +1,3 @@
@import 'element';
@import 'transition';
@import 'common';

36
css/transition.scss Normal file
View File

@ -0,0 +1,36 @@
//router-view
.view-leave-active {
opacity: 1;
transform: scaleY(1);
transition: all .5s;
transform-origin: center top;
}
.view-enter-active .view-leave-active {
transform-origin: center bottom;
}
.view-enter-from, .view-leave-active {
opacity: 0;
transform: scaleY(0);
}
//
.breadcrumb-enter-active,
.breadcrumb-leave-active {
transition: all .5s;
}
.breadcrumb-enter,
.breadcrumb-leave-active {
opacity: 0;
transform: translateX(20px);
}
.breadcrumb-move {
transition: all .5s;
}
.breadcrumb-leave-active {
position: absolute;
}

View File

@ -0,0 +1,14 @@
export default {
/**
* 安装权限指令插件
* @param {import('vue').App} app - Vue应用实例
*/
install: (app) => {
app.directive("permission", {
mounted(el, { value }) {
if (!value) throw new Error("传入的 value 参数无效");
if (window.permissions && !window.permissions.includes(value)) el.parentNode?.removeChild(el);
}
});
},
};

197
dynamicRouter/index.js Normal file
View File

@ -0,0 +1,197 @@
import { cloneDeep } from "lodash-es";
import { conversionRouterMeta, conversionNavMeta } from "../conversionRouterMeta/index.js";
import { resetQueryCriteria } from "../hooks/useQueryCriteria/index.js";
import { postRequest } from "../axios/index.js";
import Children from '../components/children/index.vue'
/**
* 动态路由配置对象
*/
let dynamicRouterConfig = {
router: null,
stores: {
routerStore: null,
menuStore: null,
userStore: null,
navStore: null
},
modules: null,
childrenComponent: Children,
getRouterUrl: '/sys/menu/nav',
loginPath: '/login',
notFoundPath: '/404',
parentRouteName: 'app'
};
// 用来获取后台拿到的路由
let storageRouter = null;
// 后台返回的默认选中的导航
let defaultSelectionNav = "";
/**
* 配置动态路由
* @param {Object} config - 配置对象
* @param {Object} config.router - Vue Router 实例
* @param {Object} config.stores - Pinia Store 实例集合
* @param {Object} config.stores.routerStore - 路由状态管理
* @param {Object} config.stores.menuStore - 菜单状态管理
* @param {Object} config.stores.userStore - 用户状态管理
* @param {Object} config.stores.navStore - 导航状态管理
* @param {Object} config.modules - 组件模块映射
* @param {Object} [config.childrenComponent] - 路由为children时渲染的组件
* @param {string} [config.getRouterUrl='/sys/menu/nav'] - 获取菜单数据的API端点要求返回 { menuList, permissions, navList, defaultSelection }
* @param {string} [config.loginPath='/login'] - 登录页面路径
* @param {string} [config.notFoundPath='/404'] - 404页面路径
* @param {string} [config.parentRouteName='app'] - 父路由名称
*/
export function configureDynamicRouter(config) {
if (!config.router) throw new Error('router 参数必传');
if (!config.stores || !config.stores.routerStore || !config.stores.menuStore ||
!config.stores.userStore || !config.stores.navStore)
throw new Error('stores (routerStore, menuStore, userStore, navStore) 必传');
if (!config.modules) throw new Error('modules 参数必传');
dynamicRouterConfig = {
router: config.router,
stores: config.stores,
modules: config.modules,
childrenComponent: config.childrenComponent || Children,
getRouterUrl: config.getRouterUrl || '/sys/menu/nav',
loginPath: config.loginPath || '/login',
notFoundPath: config.notFoundPath || '/404',
parentRouteName: config.parentRouteName || 'app'
};
setupRouterGuard();
}
/**
* 设置路由守卫
*/
function setupRouterGuard() {
const { router, stores } = dynamicRouterConfig;
router.beforeEach(async (to, from, next) => {
const { routerStore, userStore, navStore } = stores;
// 需要登陆
if (to.meta.isLogin !== false) {
if (!userStore.getUserInfo.userId) {
next(dynamicRouterConfig.loginPath);
return;
}
if (!storageRouter) {
// 变量里没有储存路由
if (routerStore.getRouters.length === 0) {
// pinia里没有储存路由去后台获取路由
const { menuList, permissions, navList, defaultSelection } = await postRequest(dynamicRouterConfig.getRouterUrl);
// 后台返回的默认选中导航
defaultSelectionNav = defaultSelection;
// 后台返回的权限
userStore.setPermissions(permissions);
// 后台返回的路由
storageRouter = conversionRouterMeta(menuList);
// 后台返回的导航
navStore.setNavList(conversionNavMeta(navList));
// 存储路由
routerStore.setRouters(storageRouter);
// 执行路由跳转方法
routerGo(to, next);
} else {
// pinia里储存了路由
// 拿到路由
storageRouter = routerStore.getRouters;
// 执行路由跳转方法
routerGo(to, next);
}
} else {
next();
}
} else {
// 不需要登陆,清空储存路由
resetDynamicRouter();
next();
}
});
}
function routerGo(to, next) {
const { router, stores, parentRouteName, notFoundPath } = dynamicRouterConfig;
const { menuStore, userStore } = stores;
// 储存权限给permission指令使用
window.permissions = userStore.getPermissions;
// 过滤路由
storageRouter = filterAsyncRouter(cloneDeep(storageRouter));
for (let i = 0; i < storageRouter.length; i++) {
// 动态添加路由
router.addRoute(parentRouteName, storageRouter[i]);
}
// 将404路由添加到最后
router.addRoute({ path: "/:pathMatch(.*)*", redirect: notFoundPath });
for (let i = 0; i < router.options.routes.length; i++) {
if (router.options.routes[i].path === "/") {
// 将路由数据存到一个新的pinia里做菜单渲染
menuStore.setMenus(router.options.routes[i].children.concat(storageRouter));
// 如果没有选中任何导航,则默认选中一个
if (!menuStore.getModel) menuStore.setModel(defaultSelectionNav);
break;
}
}
// 等待addRoute执行完毕跳转路由
next({ ...to, replace: true });
}
function filterAsyncRouter(asyncRouterMap) {
// 遍历后台传来的路由字符串,转换为组件对象
return asyncRouterMap.filter((route) => {
// 后台将name存成了routeKey将meta.title存成了name这里使用routeKey作为name
route.name = route.routeKey || "";
route.props = route.props === 1;
if (route.component) {
if (route.component === "children") {
route.component = dynamicRouterConfig.childrenComponent;
} else {
const { modules } = dynamicRouterConfig;
if (route.component.charAt(0) === "/") {
// 如果路径的第一位是/则直接使用modules里的组件
route.component = modules[`./views${route.component}.vue`];
} else {
// 如果不是,则拼接成路径
route.component = modules[`./views/${route.component}.vue`];
}
}
}
if (route.list && route.list.length) {
// 如果存在子级递归
route.children = filterAsyncRouter(route.list);
// 因为后台返回的子级是list所以需要删除
delete route.list;
}
return true;
});
}
/**
* 重置动态路由状态
* 用于登出或重新配置时清理状态
*/
export function resetDynamicRouter() {
storageRouter = null;
defaultSelectionNav = "";
const { stores } = dynamicRouterConfig;
const { routerStore, menuStore, navStore, userStore } = stores;
routerStore?.$reset();
menuStore?.$reset();
navStore?.$reset();
userStore?.$reset();
window.permissions = undefined;
resetQueryCriteria();
}
/**
* 获取当前存储的路由数据
* @returns {Array|null} 路由数据数组或null
*/
export function getStorageRouter() {
return storageRouter;
}

View File

@ -0,0 +1,35 @@
/**
* 表单项类型枚举
*/
const formItemTypeEnum = {
// 映射为el-input
INPUT: 'INPUT',
// 映射为el-input的textarea
TEXTAREA: 'TEXTAREA',
// 映射为el-input-number
INPUT_NUMBER: 'INPUT_NUMBER',
// 映射为el-input-number
NUMBER: 'NUMBER',
// 映射为el-select
SELECT: 'SELECT',
// 映射为el-radio-group
RADIO: 'RADIO',
// 映射为el-checkbox-group
CHECKBOX: 'CHECKBOX',
// 映射为el-date-picker日期格式为YYYY-MM-DD
DATE: 'DATE',
// 映射为el-date-picker日期格式为YYYY-MM
DATE_MONTH: 'DATE_MONTH',
// 映射为el-date-picker日期格式为YYYY
DATE_YEAR: 'DATE_YEAR',
// 映射为el-date-picker日期格式为YYYY-MM-DD
DATE_RANGE: 'DATE_RANGE',
// 映射为el-date-picker日期格式为YYYY-MM-DD HH:mm:ss
DATETIME: 'DATETIME',
// 映射为el-date-picker日期格式为YYYY-MM-DD HH:mm:ss
DATETIME_RANGE: 'DATETIME_RANGE',
// 映射为el-divider
DIVIDER: 'DIVIDER',
};
export default formItemTypeEnum;

View File

@ -0,0 +1,18 @@
import { ref } from "vue";
import { getDataType } from "../../utils/index.js";
/**
* @param {Function} api - 接口请求函数用于获取数据字典内容
* @param {String} id - 数据字典的id
* @returns {Array} 返回对象包含以下属性
* - {Ref<Array>} 0 - 数据字典数据使用 Vue ref 包裹的数组包含字典项列表
*/
export default function useDataDictionary(api, id) {
if (getDataType(api) !== "Function" && getDataType(api) !== "AsyncFunction")
throw new Error("api必须是一个函数");
const data = ref([]);
api({ parentId: id }).then((res) => {
data.value = res;
});
return [data];
}

View File

@ -0,0 +1,58 @@
import { ElMessage } from "element-plus";
import dayjs from "dayjs";
import {getFileUrl} from "../../utils/index.js";
/**
* @param {string} url - 请求的 API 地址GET
* @param {Object} [option] - 配置项可选
* @param {string} [option.name] - 下载文件的自定义文件名不含后缀默认为当前时间戳
* @param {string} [option.type] - Blob 对象的 MIME 类型默认为 Excel 类型
* @param {Object} [option.params] - 请求时携带的查询参数对象
* @returns {Promise<any>} 如果下载成功则 resolve 响应数据失败则 reject 错误信息
*/
export default function useDownloadBlob(
url,
option = { name: "", type: "", params: {} }
) {
const FILE_URL = getFileUrl();
return new Promise((resolve, reject) => {
const finalUrl = url.indexOf(FILE_URL) === -1 ? FILE_URL + url : url;
Object.entries(option.params).forEach(([key, value]) => {
finalUrl.searchParams.append(key, value);
});
fetch(finalUrl, {
method: "GET",
mode: "cors",
headers: {
"Content-Type": "application/json",
},
})
.then((response) => {
if (!response.ok) {
throw new Error("Network response was not ok");
}
return response.blob();
})
.then((blob) => {
// 保持原有下载逻辑
const finalBlob = new Blob([blob], {
type: option.type || "application/vnd.ms-excel",
});
const downloadElement = document.createElement("a");
const href = window.URL.createObjectURL(finalBlob);
downloadElement.style.display = "none";
downloadElement.href = href;
downloadElement.download =
option.name || dayjs().format("YYYY-MM-DD HH:mm:ss");
document.body.appendChild(downloadElement);
downloadElement.click();
document.body.removeChild(downloadElement);
window.URL.revokeObjectURL(href);
resolve({ data: finalBlob });
})
.catch((err) => {
ElMessage.error("导出失败");
reject(err);
});
});
}

View File

@ -0,0 +1,32 @@
import { ElMessage, ElMessageBox } from "element-plus";
import { getFileName, getFileSuffix, getFileUrl } from "../../utils/index.js";
/**
* @param {string} url - 文件的下载地址 URL
* @param {string} [name] - 可选的文件名不带后缀若未提供则从 URL 中提取文件名
* @returns {Promise<void>} 无返回值但会触发浏览器下载行为
*/
export default async function useDownloadFile(url, name) {
if (!url) throw new Error("没有下载地址");
await ElMessageBox.confirm("确定要下载此文件吗?", { type: "warning" });
const FILE_URL = getFileUrl();
if (name) {
if (!getFileSuffix(url)) name = name + getFileSuffix(url);
} else name = getFileName(url);
fetch(url.indexOf(FILE_URL) === -1 ? FILE_URL + url : url)
.then((res) => res.blob())
.then((blob) => {
const a = document.createElement("a");
document.body.appendChild(a);
a.style.display = "none";
const url = window.URL.createObjectURL(blob);
a.href = url;
a.download = `${name}`;
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
})
.catch(() => {
ElMessage.error("下载失败");
});
}

41
hooks/useForm/index.js Normal file
View File

@ -0,0 +1,41 @@
import { ElMessage } from "element-plus";
import { useTemplateRef } from "vue";
/**
* @returns {Object} 返回一个包含以下属性的对象
* - validate {Function} 触发表单验证的异步方法
* - formRef {Ref} 表单引用对象用于 Vue 模板绑定
* - reset {Function} 重置表单字段的方法
*/
export default function useForm() {
const formRef = useTemplateRef("formRef");
/**
* @param {string} [message="请补全必填项!"] - 验证失败时显示的提示信息
* @returns {Promise<boolean>} 验证通过返回 resolve(true)失败 reject(false)
*/
const validate = (message = "请补全必填项!") => {
return new Promise((resolve, reject) => {
formRef.value.validate((valid) => {
if (valid) {
resolve(valid);
} else {
reject(valid);
ElMessage.warning(message);
setTimeout(() => {
const element = document.querySelectorAll(
".el-form-item__error"
)[0];
element.scrollIntoView({
behavior: "smooth",
block: "center",
});
}, 100);
}
});
});
};
const reset = () => {
formRef.value.resetFields();
};
return { validate, formRef, reset };
}

View File

@ -0,0 +1,24 @@
import { uniqBy } from "lodash-es";
import { ElMessage } from "element-plus";
/**
* @param {Array} list - 需要检查重复项的目标数组
* @param {string} key - 用于去重判断的对象属性名
* @param {string} [message="存在重复项,请勿重复选择"] - 可选的错误提示信息
* @returns {Promise<void>} 如果无重复项则 resolve存在重复项时 reject 并提示错误
*/
export default function useIsExistenceDuplicateSelection(
list,
key,
message = "存在重复项,请勿重复选择"
) {
return new Promise((resolve, reject) => {
const newList = uniqBy(list, key);
if (newList.length !== list.length) {
ElMessage.error(message);
reject(new Error(message));
} else {
resolve();
}
});
}

177
hooks/useListData/index.js Normal file
View File

@ -0,0 +1,177 @@
import { nextTick, ref } from "vue";
import { getDataType } from "../../utils/index.js";
import {
getQueryCriteria,
setQueryCriteria,
} from "../useQueryCriteria/index.js";
const verificationParameter = (api, options) => {
if (getDataType(api) !== "Function") throw new Error("api必须是一个函数");
if (getDataType(options) !== "Object")
throw new Error("options必须是一个对象");
if (options.immediate && getDataType(options.immediate) !== "Boolean")
throw new Error("options.immediate必须是一个布尔值");
if (options.usePagination && getDataType(options.usePagination) !== "Boolean")
throw new Error("options.usePagination必须是一个布尔值");
if (options.key && getDataType(options.key) !== "String")
throw new Error("options.key必须是一个字符串");
if (
options.callback &&
getDataType(options.callback) !== "Function" &&
getDataType(options.callback) !== "AsyncFunction"
)
throw new Error("options.callback必须是一个函数");
if (
options.before &&
getDataType(options.before) !== "Function" &&
getDataType(options.before) !== "AsyncFunction"
)
throw new Error("options.before必须是一个函数");
if (
options.defaultSearchForm &&
getDataType(options.defaultSearchForm) !== "Object"
)
throw new Error("options.defaultSearchForm必须是一个对象");
if (
options.clearSelection &&
getDataType(options.clearSelection) !== "Boolean"
)
throw new Error("options.clearSelection必须是一个布尔值");
if (
options.params &&
getDataType(options.params) !== "Object" &&
getDataType(options.params) !== "Function"
)
throw new Error("options.params必须是一个对象或者一个函数");
if (
options.isStorageQueryCriteria &&
getDataType(options.isStorageQueryCriteria) !== "Boolean"
)
throw new Error("options.isStorageQueryCriteria必须是一个布尔值");
if (
options.tabsActiveName &&
getDataType(options.tabsActiveName) !== "String"
)
throw new Error("options.tabsActiveName必须是一个字符串");
};
const getOptionParams = (params) => {
if (params) {
if (getDataType(params) === "Object") return params;
const paramsValue = params();
if (getDataType(paramsValue) !== "Object")
throw new Error("options.params为函数时必须存在返回值并且必须是一个对象");
else return paramsValue;
}
};
const getBeforeParams = (before, params) => {
if (before) {
const paramsValue = before(JSON.parse(JSON.stringify(params)));
if (getDataType(paramsValue) !== "Object")
throw new Error("options.before必须存在返回值并且必须是一个对象");
else return paramsValue;
}
};
/**
* @param {Function} api - 接口请求函数用于获取列表数据
* @param {Object} [options] - 配置项可选
* @param {Function} [options.callback] - 数据获取完成后的回调函数第一个参数为列表数据第二个参数为接口返回数据
* @param {Function} [options.before] - 请求前执行的钩子函数接收当前查询参数作为参数必须返回一个对象
* @param {Object|Function} [options.params] - 额外的请求参数可以是对象或返回对象的函数
* @param {Object} [options.defaultSearchForm] - 搜索表单默认值
* @param {boolean} [options.immediate=true] - 是否立即执行接口请求
* @param {boolean} [options.usePagination=true] - 是否使用分页功能
* @param {string} [options.key="list"] - 响应数据中存放列表数据的字段名
* @param {boolean} [options.clearSelection=true] - 调用 resetPagination 时是否清空表格选择项
* @param {boolean} [options.isStorageQueryCriteria=true] - 是否缓存当前查询条件
* @param {string} [options.tabsActiveName] - 当存在 Tabs 组件时当前激活的 tab 名称用于区分查询缓存
* @return {Object} 返回对象包含以下属性list 表格数据pagination 分页数据searchForm 搜索表单数据tableRef 表格实例getData 获取数据函数resetPagination 重置分页函数
* @returns {Object} 返回对象包含以下属性
* - [list] {Ref<Array>} 表格数据使用 Vue ref 包裹的数组
* - [pagination] {Ref<{currentPage:number,pageSize:number,total:number}>} 分页信息对象包含 currentPagepageSizetotal 字段使用 ref 包裹
* - [searchForm] {Ref<Object>} 搜索表单数据对象使用 Vue ref 包裹
* - [tableRef] {Ref<ElTable|null>} 表格实例ref可用于调用表格方法
* - [getData] {Function} 获取或刷新列表数据的异步函数
* - [resetPagination] {Function} 重置分页并刷新数据的异步函数
*/
export default function useListData(api, options = {}) {
verificationParameter(api, options);
const immediate = options.immediate ?? true;
const usePagination = options.usePagination ?? true;
const key = options.key ?? "list";
const defaultSearchForm = options.defaultSearchForm ?? {};
const clearSelection = options.clearSelection ?? true;
const isStorageQueryCriteria = options.isStorageQueryCriteria ?? true;
const defaultPagination = { currentPage: 1, pageSize: 20, total: 0 };
const list = ref([]);
const queryCriteria = getQueryCriteria(options.tabsActiveName);
const pagination = ref(queryCriteria.pagination || defaultPagination);
const searchForm = ref(JSON.parse(JSON.stringify(defaultSearchForm)));
const tableRef = ref(null);
const getData = async () => {
const resData = await api({
...(usePagination
? {
curPage: pagination.value.currentPage,
limit: pagination.value.pageSize,
}
: {}),
...searchForm.value,
...(queryCriteria.searchForm || {}),
...(getBeforeParams(
options.before,
queryCriteria.searchForm || searchForm.value
) || {}),
...(getOptionParams(options.params) || {}),
});
if (usePagination) {
if (resData.page[key]) list.value = resData.page[key];
else list.value = resData[key] || resData.varList;
} else list.value = resData[key] || resData.varList;
if (usePagination)
pagination.value.total =
resData.page.totalCount || resData.page.totalResult;
options.callback && options.callback(list.value, resData);
!usePagination &&
clearSelection &&
tableRef.value &&
tableRef.value.clearSelection();
if (isStorageQueryCriteria) {
setQueryCriteria(
{
searchForm: {
...searchForm.value,
...(queryCriteria.searchForm || {}),
},
pagination: pagination.value,
},
options.tabsActiveName
);
await nextTick();
searchForm.value = queryCriteria.searchForm || searchForm.value;
}
};
immediate && getData().then();
const resetPagination = async () => {
list.value = [];
pagination.value = defaultPagination;
await nextTick();
await getData();
clearSelection && tableRef.value && tableRef.value.clearSelection();
const cloneSearchForm = searchForm.value;
searchForm.value = {};
await nextTick();
searchForm.value = cloneSearchForm;
};
return {
list,
pagination,
searchForm,
tableRef,
getData: async () => await getData(),
resetPagination: async () => await resetPagination(),
};
}

View File

@ -0,0 +1,43 @@
import { useQueryCriteriaStore } from "../../pinia/queryCriteria/index.js";
function getCriteriaKey(tabsActiveName) {
const key = window.location.href;
return tabsActiveName ? `${key}/${tabsActiveName}` : key;
}
export const getQueryCriteria = (tabsActiveName) => {
const miscellaneousStore = useQueryCriteriaStore();
const criteriaKey = getCriteriaKey(tabsActiveName);
const queryCriteria = miscellaneousStore.getQueryCriteria[criteriaKey] || {};
return {
pagination: queryCriteria.pagination,
searchForm: queryCriteria.searchForm,
tabsActiveName,
};
};
export const setQueryCriteria = (data, tabsActiveName) => {
const miscellaneousStore = useQueryCriteriaStore();
const criteriaKey = getCriteriaKey(tabsActiveName);
miscellaneousStore.setQueryCriteria({
...miscellaneousStore.getQueryCriteria,
[criteriaKey]: {
...miscellaneousStore.getQueryCriteria[criteriaKey],
...data,
},
});
};
export const resetQueryCriteria = () => {
const miscellaneousStore = useQueryCriteriaStore();
miscellaneousStore.resetQueryCriteria();
};
export const getTabsActiveName = () => {
const miscellaneousStore = useQueryCriteriaStore();
return miscellaneousStore.getQueryCriteriaTabsActiveName;
};
export const setTabsActiveName = (name) => {
const miscellaneousStore = useQueryCriteriaStore();
miscellaneousStore.setQueryCriteriaTabsActiveName(name);
};

View File

@ -0,0 +1,20 @@
import { customRef } from "vue";
const loading = customRef((track, trigger) => {
let loadingCount = 0;
return {
get() {
track();
return loadingCount > 0;
},
set(value) {
loadingCount += value ? 1 : -1;
loadingCount = Math.max(0, loadingCount);
trigger();
},
};
});
export default function useRequestLoading() {
return loading;
}

View File

@ -0,0 +1,41 @@
import { unref } from "vue";
import {uploadRequest} from "../../axios/index.js";
/**
* @description 用于处理单个或多个文件上传传入数组是批量上传传入对象是单个上传
* @param {File|File[]} params - 要上传的文件或文件数组可以是响应式对象
* @param {string|number} type - 文件类型标识
* @param {Object} [api] - 可选的自定义 API 对象
* @param {Function} [api.batch="/img-files/batch"] - 批量上传文件的 API 函数接受 formData 参数
* @param {Function} [api.single="/img-files"] - 单个文件上传的 API 函数接受 formData 参数
* @returns {Promise<File|File[]>} 上传成功后返回包含文件信息的对象或数组传入数组会返回数组传入对象会返回对象
* */
export default async function useUploadFile(
params,
type,
api = {
batch: (formData) => uploadRequest('/img-files/batch', formData),
single: (formData) => uploadRequest('/img-files', formData),
}
) {
const file = unref(params);
const isArray = Array.isArray(file);
const formData = new FormData();
if (isArray) {
file.forEach((f) => {
f.raw && formData.append("files", f.raw);
});
} else {
file.raw && formData.append("files", file.raw);
}
formData.append("type", type);
const filesLength = formData.getAll("files").length;
if (filesLength === 0) return file;
if (isArray) {
const { data } = await api.batch(formData);
return [...data, ...file.filter((f) => !f.raw)];
} else {
const { data } = await api.single(formData);
return data;
}
}

97
index.js Normal file
View File

@ -0,0 +1,97 @@
// 组件库主入口文件
// =============================================================================
// 导入所有组件
// =============================================================================
import AppFormBuilder from './components/form_builder/index.vue';
import AppTable from './components/table/index.vue';
import AppSearch from './components/search/index.vue';
import AppUpload from './components/upload/index.vue';
import AppPagination from './components/pagination/index.vue';
import AppEditor from './components/editor/index.vue';
import AppQrCode from './components/qr_code/index.vue';
import AppInfoBuilder from './components/info_builder/index.vue';
import AppPreviewImg from './components/preview_img/index.vue';
import AppTooltipImg from './components/tooltip_img/index.vue';
import AppPdf from './components/pdf/index.vue';
import AppPreviewPdf from './components/preview_pdf/index.vue';
import AppAliPlayer from './components/ali-player/index.vue';
import AppVideo from './components/video/index.vue';
import AppSign from './components/sign/index.vue';
import AppVerification from './components/verification/index.vue';
import AppVerificationCode from './components/verification_code/index.vue';
import AppTxt from './components/txt/index.vue';
import AppViewTree from './components/view_tree/index.vue';
import AppMap from './components/map/index.vue';
import AppMapSelector from './components/map/map.vue';
import AppImportFile from './components/import_file/index.vue';
import AppCascader from './components/cascader/index.vue';
import AppLayout from './layout/index.vue';
// =============================================================================
// 导入所有工具函数和hooks
// =============================================================================
// Axios
export { configureAxios, postRequest, getRequest, putRequest, deleteRequest, patchRequest, uploadRequest } from './axios/index.js';
// Hooks 相关
export { default as useDataDictionary } from './hooks/useDataDictionary/index.js';
export { default as useDownloadBlob } from './hooks/useDownloadBlob/index.js';
export { default as useDownloadFile } from './hooks/useDownloadFile/index.js';
export { default as useForm } from './hooks/useForm/index.js';
export { default as useIsExistenceDuplicateSelection } from './hooks/useIsExistenceDuplicateSelection/index.js';
export { default as useListData } from './hooks/useListData/index.js';
export { default as useRequestLoading } from './hooks/useRequestLoading/index.js';
export { default as useUploadFile } from './hooks/useUploadFile/index.js';
export * from './hooks/useQueryCriteria/index.js';
// 工具函数
export * from './utils/index.js';
export * from './regular/index.js';
export { conversionRouterMeta, conversionNavMeta } from './conversionRouterMeta/index.js';
export { default as formItemTypeEnum } from './enum/formItemType/index.js';
// 指令
export { default as permissionDirective } from './directives/permission/index.js';
// Pinia Store
export * from './pinia/queryCriteria/index.js';
// 动态路由
export { configureDynamicRouter, resetDynamicRouter, getStorageRouter } from './dynamicRouter/index.js';
// AES加密服务
export { configureAesSecret, aesEncrypt, aesDecrypt } from './aesSecret/index.js';
// =============================================================================
// 按需导出组件
// =============================================================================
export {
AppFormBuilder,
AppTable,
AppSearch,
AppUpload,
AppPagination,
AppEditor,
AppQrCode,
AppInfoBuilder,
AppPreviewImg,
AppTooltipImg,
AppPdf,
AppPreviewPdf,
AppAliPlayer,
AppVideo,
AppSign,
AppVerification,
AppVerificationCode,
AppTxt,
AppViewTree,
AppMap,
AppMapSelector,
AppImportFile,
AppCascader,
AppLayout,
};

View File

@ -0,0 +1,62 @@
<template>
<div style="display: flex; justify-content: space-between; align-items: center">
<div class="breadcrumb">
<el-breadcrumb class="app-breadcrumb" separator=">">
<transition-group name="breadcrumb">
<el-breadcrumb-item v-for="(item, index) in breadcrumbList" :key="item.path">
<router-link v-if="index === 0" :to="item.path">
{{ item.meta.title }}
</router-link>
<span v-else-if="index !== breadcrumbList.length - 1">
{{ item.meta.title }}
</span>
<span v-else class="no-redirect">{{ item.meta.title }}</span>
</el-breadcrumb-item>
</transition-group>
</el-breadcrumb>
</div>
</div>
</template>
<script setup>
import { ref, watch } from "vue";
import { useRoute } from "vue-router";
import { ElBreadcrumb, ElBreadcrumbItem } from 'element-plus'
import "element-plus/es/components/breadcrumb/style/css";
import "element-plus/es/components/breadcrumb-item/style/css";
defineOptions({
name: "LayoutBreadcrumb",
});
const route = useRoute();
const breadcrumbList = ref([]);
const fnGetBreadcrumb = () => {
const matched = route.matched.filter((item) => item.meta?.title);
if (matched[0].path === "/") matched[0].path = "/index";
breadcrumbList.value = matched.filter(
(item) => item.meta?.title && item.meta?.breadcrumb !== false
);
};
fnGetBreadcrumb();
watch(
() => route,
() => fnGetBreadcrumb(),
{ deep: true }
);
</script>
<style lang="scss" scoped>
.app-breadcrumb.el-breadcrumb {
font-size: 14px;
line-height: var(--el-header-height);
.no-redirect {
color: #97a8be;
cursor: text;
}
.el-breadcrumb__inner a {
font-weight: normal;
}
}
</style>

View File

@ -0,0 +1,72 @@
<template>
<el-dialog v-model="visible" title="修改密码" width="600px" @close="fnClose">
<app-form-builder v-model="form" :options="options" label-width="80px" ref="formRef" :span="24" />
<template #footer>
<el-button type="primary" @click="fnSubmit"></el-button>
<el-button @click="fnClose"></el-button>
</template>
</el-dialog>
</template>
<script setup>
import {debounce} from "throttle-debounce";
import useForm from "../../../hooks/useForm/index.js";
import { ref } from "vue";
import { postRequest } from "../../../axios/index.js";
import AppFormBuilder from "../../../components/form_builder/index.vue";
import { STRONG_PASSWORD } from "../../../regular/index.js";
import { ElDialog, ElButton } from 'element-plus'
import "element-plus/es/components/dialog/style/css";
import "element-plus/es/components/button/style/css";
const props = defineProps({
changePasswordUrl: { type: String, required: true },
})
const emits = defineEmits(["submit"]);
const visible = defineModel("visible", { type: Boolean, required: true });
const form = ref({
password: "",
newPassword: "",
newPasswordConfirm: "",
});
const { formRef, validate, reset } = useForm();
const options = [
{ key: "password", label: "旧密码", attrs: { type: "password" } },
{
key: "newPassword",
label: "新密码",
attrs: { type: "password" },
rules: { pattern: STRONG_PASSWORD, message: "至少8个字符包含大小写字母、数字和特殊字符", trigger: "blur" }
},
{
key: "newPasswordConfirm",
label: "确认密码",
attrs: { type: "password" },
rules: {
required: true,
validator: (_rule, value, callback) => {
if (value === "") callback(new Error("请再次输入新密码"));
else if (value !== form.value.newPassword) callback(new Error("两次输入密码不一致!"));
else callback();
},
trigger: "blur"
}
},
]
const fnClose = () => {
reset();
visible.value = false;
};
const fnSubmit = debounce(
1000,
async () => {
await validate();
await postRequest(props.changePasswordUrl, {...form.value});
fnClose();
emits("submit");
},
{ atBegin: true }
);
</script>
<style scoped lang="scss"></style>

206
layout/header/index.vue Normal file
View File

@ -0,0 +1,206 @@
<template>
<div class="header">
<div class="logo">
<slot name="logo">{{ logoValueTitle }}</slot>
</div>
<div class="breadcrumb">
<layout-breadcrumb v-if="route.meta.isBreadcrumb !== false" />
</div>
<div class="right">
<slot name="userOtherInfo"></slot>
<div class="menu ml-20 mr-20">
<slot name="NavBar">
<el-dropdown trigger="click" placement="bottom-end" @command="fnMenuDropdownCommand" v-if="useMoreNavBar">
<div class="more">
<div>更多导航栏</div>
<icon-down theme="filled" size="16" fill="#a2c2d3" :stroke-width="3"/>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="(item, index) in menuList"
:key="index"
:command="item.model"
:style="{ color: item.model !== model ? 'var(--el-text-color-regular)' : '#79bbff' }"
>
{{ item.title }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<div v-else class="navs">
<div
:class="['nav', { active: item.model === model }]"
v-for="(item, index) in menuList"
:key="index"
@click="fnMenuDropdownCommand(item.model)"
>
<div>{{ item.title }}</div>
<div class="border" />
</div>
</div>
</slot>
</div>
<div class="user_dropdown">
<el-dropdown trigger="click" placement="bottom-end" @command="fnUserDropdownCommand">
<div class="user_info">
<el-avatar shape="circle" :size="30" fit="fill" :src="txImg" />
<span>{{ userName }}</span>
<icon-down theme="filled" size="16" fill="#a2c2d3" :stroke-width="3"/>
</div>
<template #dropdown>
<el-dropdown-menu>
<slot name="dropdown"></slot>
<el-dropdown-item command="modifyPassword">修改密码</el-dropdown-item>
<el-dropdown-item command="signOut">退出</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<change-password :change-password-url="changePasswordUrl" v-model:visible="passwordDialogVisible" @submit="fnSignOut" />
</div>
</template>
<script setup>
import { ref, computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import LayoutBreadcrumb from "../breadcrumb/index.vue";
import ChangePassword from "./components/change_password.vue";
import { postRequest } from "../../axios/index.js";
import { ElDropdown, ElDropdownItem, ElDropdownMenu, ElAvatar } from 'element-plus'
import "element-plus/es/components/dropdown/style/css";
import "element-plus/es/components/dropdown-item/style/css";
import "element-plus/es/components/dropdown-menu/style/css";
import "element-plus/es/components/avatar/style/css";
defineOptions({
name: "LayoutHeader",
});
const props = defineProps({
loginPath: { type: String, required: true },
logoutUrl: { type: String, required: true },
changePasswordUrl: { type: String, required: true },
txImg: { type: String, required: true },
userName: { type: String, required: true },
useMoreNavBar: { type: Boolean, required: true },
menuStore: { type: Object, required: true },
userStore: { type: Object, required: true },
navStore: { type: Object, required: true },
})
const emits = defineEmits(["command"]);
const route = useRoute();
const router = useRouter();
const menuList = computed(() => props.navStore.getNavList);
const model = computed(() => props.menuStore.getModel);
const logoValueTitle = computed(
() => menuList.value.filter((item) => item.model === model.value)?.[0]?.title
);
const passwordDialogVisible = ref(false);
const fnUserDropdownCommand = async (command) => {
if (command === "signOut") await fnSignOut();
if (command === "modifyPassword") passwordDialogVisible.value = true;
emits('command', command)
};
const fnMenuDropdownCommand = (command) => {
props.menuStore.setModel(command);
};
const fnSignOut = async () => {
await postRequest(props.logoutUrl);
props.userStore.$reset();
await router.replace(props.loginPath);
};
</script>
<style lang="scss" scoped>
.header {
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 1px 4px rgb(0 21 41 / 8%);
padding-right: 20px;
background-color: #fff;
.logo {
width: 210px;
height: 50px;
background-color: #2b2f3a;
color: #ffffff;
text-align: center;
line-height: 50px;
margin-right: 20px;
}
.right {
flex: 1;
display: flex;
align-items: center;
justify-content: flex-end;
.menu {
cursor: pointer;
height: var(--el-header-height);
.el-dropdown {
line-height: var(--el-header-height);
}
.more {
display: flex;
align-items: center;
}
.navs {
display: flex;
gap: 20px;
align-items: center;
height: 100%;
line-height: var(--el-header-height);
.nav {
color: #222;
position: relative;
&:hover {
color: #79bbff;
}
&.active {
color: #79bbff;
.border {
width: 100%;
}
}
.border {
position: absolute;
bottom: 0;
left: 50%;
width: 0;
height: 2px;
background-color: #79bbff;
transition: all 0.3s;
transform-origin: center;
transform: translateX(-50%);
}
}
}
}
.user_dropdown {
margin-top: 5px;
cursor: pointer;
.el-avatar {
margin-right: 5px;
}
.user_info {
display: flex;
align-items: center;
}
}
}
}
</style>

BIN
layout/header/tx.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

156
layout/index.vue Normal file
View File

@ -0,0 +1,156 @@
<template>
<el-container>
<el-header>
<layout-header
:login-path="loginPath"
:logout-url="logoutUrl"
:change-password-url="changePasswordUrl"
:tx-img="txImg"
:user-name="userName"
:use-more-nav-bar="useMoreNavBar"
:menu-store="menuStore"
:user-store="userStore"
:nav-store="navStore"
@command="emits('command', $event)"
>
<template #logo>
<slot name="logo"></slot>
</template>
<template #userOtherInfo>
<slot name="userOtherInfo"></slot>
</template>
<template #NavBar>
<slot name="NavBar"></slot>
</template>
<template #dropdown>
<slot name="dropdown"></slot>
</template>
</layout-header>
</el-header>
<el-container>
<el-aside>
<el-scrollbar style="height: calc(100vh - 50px)">
<el-menu
router
unique-opened
:default-active="route.meta.props && route.name ? route.name : route.meta.activeMenu || route.path"
background-color="rgb(48, 65, 86)"
text-color="#edf7ff"
active-text-color="#409eff"
>
<layout-menu :menus="routes" />
</el-menu>
</el-scrollbar>
</el-aside>
<el-main>
<el-scrollbar style="height: calc(100vh - 50px)">
<router-view v-slot="{ Component }">
<transition name="view" mode="out-in">
<el-card :key="route.path">
<el-page-header v-if="route.meta.isBack !== false" title="返回" @back="router.back()">
<template #content>
{{route.matched.filter((item) => item.meta?.title).at(-1).meta.title }}
</template>
</el-page-header>
<component :is="Component"></component>
</el-card>
</transition>
</router-view>
</el-scrollbar>
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import LayoutHeader from "./header/index.vue";
import LayoutMenu from "./menu/index.vue";
import { computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import tx from './header/tx.png'
import {
ElContainer,
ElHeader,
ElAside,
ElScrollbar,
ElMenu,
ElCard,
ElMain,
ElPageHeader
} from 'element-plus'
import "element-plus/es/components/container/style/css";
import "element-plus/es/components/header/style/css";
import "element-plus/es/components/aside/style/css";
import "element-plus/es/components/scrollbar/style/css";
import "element-plus/es/components/menu/style/css";
import "element-plus/es/components/card/style/css";
import "element-plus/es/components/main/style/css";
import "element-plus/es/components/page-header/style/css";
defineOptions({
name: "Layout",
});
const props = defineProps({
menuStore: { type: Object, required: true },
userStore: { type: Object, required: true },
navStore: { type: Object, required: true },
loginPath: { type: String, default: "/login" },
logoutUrl: { type: String, default: "/sys/logout" },
changePasswordUrl: { type: String, default: "/sys/user/password" },
txImg: { type: String, default: tx },
userName: { type: String, default: '' },
useMoreNavBar: { type: Boolean, default: true },
});
const emits = defineEmits(["command"]);
const router = useRouter();
const route = useRoute();
const routes = computed(() => props.menuStore.getMenus);
</script>
<style scoped lang="scss">
.el-header {
--el-header-padding: 0;
--el-header-height: 50px;
}
.el-aside {
width: 210px;
background-color: rgb(48, 65, 86);
}
.el-menu {
width: 210px;
min-height: calc(100vh - 50px);
border-right: none;
background-color: rgb(48, 65, 86);
:deep {
span {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
:deep {
.el-sub-menu__title *,
.el-menu-item * {
vertical-align: middle !important;
}
.is-always-shadow {
box-shadow: none !important;
}
}
.el-main {
--el-main-padding: 0;
}
.el-card {
margin: 20px;
border: none !important;
min-height: calc(100vh - 90px);
}
</style>

83
layout/menu/index.vue Normal file
View File

@ -0,0 +1,83 @@
<template>
<template v-for="menu in props.menus" :key="menu.path">
<!-- 没有二级导航-->
<el-menu-item
v-if="fnIsShowMenuItem(menu)"
:index="fnMenuItemIndex(menu)"
:route="fnMenuItemRoute(menu)"
@click="resetQueryCriteria"
>
<component
:is="'icon-' + menu.meta?.icon"
v-if="menu.meta?.icon"
theme="filled"
fill="#a5b2c2"
size="18"
:stroke-width="3"
style="margin-right: 15px"
/>
<span>{{ menu.meta?.title }}</span>
</el-menu-item>
<!-- 有二级导航-->
<el-sub-menu v-else-if="fnIsShowSubmenu(menu)" :index="menu.path">
<template #title>
<component
:is="'icon-' + menu.meta?.icon"
v-if="menu.meta?.icon"
theme="filled"
fill="#bfcbd9"
size="18"
:stroke-width="3"
style="margin-right: 10px"
/>
<span>{{ menu.meta?.title }}</span>
</template>
<!-- 递归调用当前组件生成导航-->
<layout-menu :menus="menu.children" />
</el-sub-menu>
</template>
</template>
<script setup>
import { resetQueryCriteria } from "../../hooks/useQueryCriteria/index.js";
import { ElMenuItem, ElSubMenu } from 'element-plus'
import "element-plus/es/components/menu-item/style/css";
import "element-plus/es/components/sub-menu/style/css";
defineOptions({
name: "LayoutMenu",
});
const props = defineProps({
menus: { type: Array, default: () => [] },
});
const fnIsShowMenuItem = (menu) => {
if (menu.meta?.isMenu === false) {
return false;
}
if (menu.meta?.isSubMenu === false) {
return true;
} else {
return !menu.children || menu.children.length === 0;
}
};
const fnIsShowSubmenu = (menu) => {
if (menu.meta?.isMenu === false) {
return false;
}
if (menu.meta?.isSubMenu === false) {
return false;
} else {
return menu.children && menu.children.length > 0;
}
};
const fnMenuItemIndex = (menu) => {
if (menu.name && menu.props) return menu.name;
return menu.path;
};
const fnMenuItemRoute = (menu) => {
if (menu.name && menu.props) return { name: menu.name };
return { path: menu.path };
};
</script>
<style scoped lang="scss"></style>

6
npm Normal file
View File

@ -0,0 +1,6 @@
# npm地址
https://www.npmjs.com/package/zy-vue-library
# npm账号
liujianan15703339975
Ljn15703339975.

56
package.json Normal file
View File

@ -0,0 +1,56 @@
{
"name": "zy-vue-library",
"private": false,
"version": "1.1.5",
"type": "module",
"description": "",
"author": "LiuJiaNan",
"license": "MIT",
"module": "index.js",
"files": [
"aesSecret",
"axios",
"components",
"conversionRouterMeta",
"css",
"directives",
"dynamicRouter",
"enum",
"hooks",
"layout",
"pinia",
"regular",
"utils",
"index.js",
"README.md",
"LICENSE"
],
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"element-plus": "^2.11.2",
"vue": "^3.5.0",
"vue-router": "^4.5.0"
},
"scripts": {
"postinstall": "echo 'Thanks for using our component library!'"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@vue-office/pdf": "^2.0.10",
"@vueuse/core": "^12.8.2",
"@vueuse/integrations": "^12.8.2",
"@wangeditor/editor-for-vue": "^5.1.12",
"axios": "^1.12.2",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.18",
"lodash-es": "^4.17.21",
"pinia": "^3.0.3",
"qrcode": "^1.5.4",
"throttle-debounce": "^5.0.2",
"v-viewer": "^3.0.22",
"vue-esign": "^1.1.4",
"vue3-puzzle-vcode": "^1.1.7"
}
}

View File

@ -0,0 +1,31 @@
import { defineStore } from "pinia";
export const useQueryCriteriaStore = defineStore("queryCriteriaStore", {
state: () => ({
queryCriteria: {},
queryCriteriaTabsActiveName: "",
}),
getters: {
getQueryCriteria() {
return this.queryCriteria;
},
getQueryCriteriaTabsActiveName() {
return this.queryCriteriaTabsActiveName;
},
},
actions: {
setQueryCriteria(data) {
this.queryCriteria = data;
},
setQueryCriteriaTabsActiveName(data) {
this.queryCriteriaTabsActiveName = data;
},
resetQueryCriteria() {
this.queryCriteria = {};
this.queryCriteriaTabsActiveName = "";
},
},
persist: {
storage: window.sessionStorage,
},
});

56
regular/index.js Normal file
View File

@ -0,0 +1,56 @@
/**
* 匹配中国手机号码可包含国家代码86支持各种运营商号段
*/
export const PHONE =
/^(?:(?:\+|00)86)?1(?:(?:3[\d])|(?:4[5-7|9])|(?:5[0-3|5-9])|(?:6[5-7])|(?:7[0-8])|(?:8[\d])|(?:9[1|8|9]))\d{8}$/;
/**
* 匹配中国大陆的统一社会信用代码
*/
export const UNIFIED_SOCIAL_CREDIT_CODE =
/^[0-9A-HJ-NPQRTUWXY]{2}\d{6}[0-9A-HJ-NPQRTUWXY]{10}$/;
/**
* 匹配中国大陆的身份证号码包括15位和18位号码并验证最后一位校验码
*/
export const ID_NUMBER =
/^[1-9]\d{5}(?:18|19|20)\d{2}(?:0[1-9]|10|11|12)(?:0[1-9]|[1-2]\d|30|31)\d{3}[\dXx]$/;
/**
* 匹配中国大陆的移动电话号码不包含国家代码
*/
export const MOBILE_PHONE =
/^(13[0-9]|14[579]|15[0-3,5-9]|16[6]|17[0135678]|18[0-9]|19[89])\d{8}$/;
/**
* 匹配浮点数允许整数一位或两位小数以及零的情况
*/
export const FLOATING_POINT_NUMBER =
/(^[1-9]([0-9]+)?(\.[0-9]{1,2})?$)|(^(0){1}$)|(^[0-9]\.[0-9]([0-9])?$)/;
/**
* 两位小数
*/
export const TWO_DECIMAL_PLACES = /^\d+\.\d{2}$/;
/**
* 一位小数非必须
*/
export const ONE_DECIMAL_PLACES = /^\d+(\.\d{0,1})?$/;
/**
* 匹配中国大陆的车牌号码
*/
export const LICENSE_PLATE_NUMBER =
/^([京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领A-Z]{1}[A-Z]{1}[A-Z0-9]{4}[A-Z0-9挂学警港澳]{1})$/;
/**
* 匹配强密码要求至少8个字符包含大小写字母数字和特殊字符
*/
export const STRONG_PASSWORD =
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^a-zA-Z\d]).{8,}$/;
/**
* 匹配完整的HTML标签包括开始标签和结束标签
*/
export const HTML_TAG = /<\/?[^>]*>/g;

520
utils/index.js Normal file
View File

@ -0,0 +1,520 @@
import { ElMessage } from "element-plus";
/**
* @description 计算序号
* @param {Object} pagination 分页数据对象
* @param {number | string} pagination.currentPage 当前页
* @param {number | string} pagination.pageSize 每页条数
* @param {number} index 当页数据的索引值
* @return {number} 序号
**/
export function serialNumber(pagination, index) {
return (pagination.currentPage - 1) * pagination.pageSize + (index + 1);
}
/**
* @description 字符串数组转数组
* @param {string} value 转换的字符串数组
* @return {Array} 转换后的数组
**/
export function toArrayString(value) {
// eslint-disable-next-line no-eval
return value ? eval(value).map(String) : [];
}
/**
* @description 判断文件后缀名是否符合
* @param {string} name 文件名字
* @param {string} suffix 文件后缀
* @return {boolean} 是否符合
**/
export function interceptTheSuffix(name, suffix) {
return (
name.substring(name.lastIndexOf("."), name.length).toLowerCase() ===
suffix.toLowerCase()
);
}
/**
* @description 图片转base64
* @param {string} imgUrl 图片地址
* @return {Promise} Promise实例then包含base64编码
**/
export function image2Base64(imgUrl) {
return new Promise((resolve) => {
const img = new Image();
img.src = imgUrl;
img.crossOrigin = "Anonymous";
img.onload = function () {
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0, img.width, img.height);
const ext = img.src.substring(img.src.lastIndexOf(".") + 1).toLowerCase();
resolve(canvas.toDataURL("image/" + ext));
};
});
}
export function image2Base642(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (e) => {
resolve(e.target.result); // 返回 base64
};
reader.onerror = (error) => {
reject(error); // 处理错误
};
});
}
/**
* @description 判断图片是否可访问成功
* @param {string} imgUrl 图片地址
* @return {Promise} Promise实例
**/
export function checkImgExists(imgUrl) {
return new Promise((resolve, reject) => {
const ImgObj = new Image();
ImgObj.src = imgUrl;
ImgObj.onload = function (res) {
resolve(res);
};
ImgObj.onerror = function (err) {
reject(err);
};
});
}
/**
* @description 获取数据类型
* @param {any} data 数据
* @return {string} 数据类型
**/
export function getDataType(data) {
return Object.prototype.toString.call(data).slice(8, -1);
}
/**
* @description 数组去重
* @param {Array<number,string>} arr 去重的数组
* @return {Array} 去重后的数组
**/
export function ArrayDeduplication(arr) {
return [...new Set(arr)];
}
/**
* @description 数组对象去重
* @param {Array} arr 去重的数组
* @param {string} name 去重的key
* @return {Array} 去重后的数组
**/
export function arrayObjectDeduplication(arr, name) {
const obj = {};
arr = arr.reduce(function (previousValue, currentValue) {
if (!obj[currentValue[name]]) {
obj[currentValue[name]] = true;
previousValue.push(currentValue);
}
return previousValue;
}, []);
return arr;
}
/**
* @description 查找字符串中指定的值第几次出现的位置
* @param {Array} str 查找的字符串数组
* @param {string} char 查找的值
* @param {number} num 第几次出现
* @return {number} 出现的位置
**/
export function findCharIndex(str, char, num) {
let index = str.indexOf(char);
if (index === -1) return -1;
for (let i = 0; i < num - 1; i++) {
index = str.indexOf(char, index + 1);
if (index === -1) return -1;
}
return index;
}
/**
* @description 生成指定两个值之间的随机数
* @param {number} min 最小值
* @param {number} max 最大值
* @return {number} 随机数
**/
export function randoms(min, max) {
return Math.random() * (max - min + 1) + min;
}
/**
* @description 千位分隔符
* @param {number | string} num 转换的值
* @return {string} 转换后的值
**/
export function numFormat(num) {
if (num) {
const numArr = num.toString().split(".");
const arr = numArr[0].split("").reverse();
let res = [];
for (let i = 0; i < arr.length; i++) {
if (i % 3 === 0 && i !== 0) {
res.push(",");
}
res.push(arr[i]);
}
res.reverse();
if (numArr[1]) {
res = res.join("").concat("." + numArr[1]);
} else {
res = res.join("");
}
return res;
}
}
/**
* @description 验证是否为空
* @param {any} value 验证的值
* @return {boolean} 是否为空
**/
export function isEmpty(value) {
return (
value === undefined ||
value === null ||
(typeof value === "object" && Object.keys(value).length === 0) ||
(typeof value === "string" && value.trim().length === 0)
);
}
/**
* @description 获取url参数
* @param {string} name 获取的key
* @return {string} 获取的值
**/
export function getUrlParam(name) {
const reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)");
const r = window.location.search.substr(1).match(reg);
if (r != null) return decodeURI(r[2]);
return "";
}
/**
* @description 数据分页
* @param {Array} list 分页的数组
* @param {number | string} currentPage 当前页
* @param {number | string} pageSize 每页条数
* @return {Array} 分页后的数组
**/
export function paging(list, currentPage, pageSize) {
return list.filter((item, index) => {
return (
index < +currentPage * +pageSize &&
index >= (+currentPage - 1) * +pageSize
);
});
}
/**
* @description 获取文件后缀
* @param {string} name 文件名
* @return {string} 文件后缀
**/
export function getFileSuffix(name) {
return name.substring(name.lastIndexOf(".") + 1);
}
/**
* @description 获取文件名称
* @param {string} name 文件地址
* @return {string} 文件名称
**/
export function getFileName(name) {
if (!name) return "";
return name.substring(name.lastIndexOf("/") + 1);
}
/**
* @description 读取txt文档
* @param {string} filePah 文档路径
* @return {resolve,string} 读取后的内容
**/
export function readTxtDocument(filePah) {
return new Promise((resolve) => {
const FILE_URL = getFileUrl();
const file_url = FILE_URL + filePah;
const xhr = new XMLHttpRequest();
xhr.open("get", file_url, true);
xhr.responseType = "blob";
xhr.onload = function (event) {
const reader = new FileReader();
reader.readAsText(event.target.response, "GB2312");
reader.onload = function () {
resolve(reader.result);
};
};
xhr.send();
});
}
/**
* @description 将秒转换成时分秒
* @param {string,number} second 需要转换的秒数
* @return {string} 转换后的时间
**/
export function secondConversion(second) {
if (!second) return 0;
const h = parseInt(second / 60 / 60, 10);
const m = parseInt((second / 60) % 60, 10);
const s = parseInt(second % 60, 10);
if (h) {
return h + "小时" + m + "分钟" + s + "秒";
} else {
if (m) {
return m + "分钟" + s + "秒";
} else {
return s + "秒";
}
}
}
/**
* @description 附件添加前缀
* @param {Array} list 附件数组
* @param {Object} options 配置选项
* @param {string} [options.pathKey="filePath"] 附件路径字段名
* @param {string} [options.nameKey="fileName"] 附件名称字段名
* @param {string} [options.idKey="imgFilesId"] 附件id字段名
* @return {Array} 添加完前缀后的数组
**/
export function addingPrefixToFile(list, options = {}) {
if (!list) return [];
const {
pathKey = "filePath",
nameKey = "fileName",
idKey = "imgFilesId",
} = options;
const FILE_URL = getFileUrl();
for (let i = 0; i < list.length; i++) {
list[i].url = FILE_URL + list[i][pathKey];
list[i].name = list[i][nameKey] || getFileName(list[i][pathKey]);
list[i].imgFilesId = list[i][idKey];
}
return list;
}
/**
* @description 验证重复选择
* @param {Array} list 验证的数组
* @param {number} index 选择的索引
* @param {string} key 验证的字段
* @param {string} id 验证的值
**/
export async function verifyDuplicateSelection(list, index, key, id) {
return new Promise((resolve, reject) => {
if (list.some((item) => item[key] === id)) {
ElMessage.warning("不能重复选择");
reject(new Error("不能重复选择"));
} else {
list[index][key] = id;
resolve();
}
});
}
/**
* @description 翻译状态
* @param {number | string} status 状态
* @param {Array} list 翻译的数组
* @param {String} idKey
* @param {String} nameKey
* @return {string} 翻译后的状态
**/
export function getLabelName(status, list, idKey = "id", nameKey = "name") {
for (let i = 0; i < list.length; i++) {
if (status?.toString() === list[i][idKey]?.toString()) {
return list[i][nameKey];
}
}
}
/**
* @description 计算文件大小
* @param {number | string} size 文件kb
* @return {string} 计算后的文件大小
**/
export function calculateFileSize(size) {
return size > 1024
? (size / 1024 + "").substring(0, (size / 1024 + "").lastIndexOf(".") + 3) +
"MB"
: size + "KB";
}
/**
* @description 根据身份证号获取出生日期和性别
* @param {String} idCard 身份证号
* @return {Object} 出生日期和性别 date sex
**/
export function idCardGetDateAndGender(idCard) {
const reg =
/^[1-9]\d{5}(18|19|([23]\d))\d{2}((0[1-9])|(10|11|12))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/;
let sex = "";
let date = "";
if (reg.test(idCard)) {
const org_birthday = idCard.substring(6, 14);
const org_gender = idCard.substring(16, 17);
const birthday =
org_birthday.substring(0, 4) +
"-" +
org_birthday.substring(4, 6) +
"-" +
org_birthday.substring(6, 8);
const birthdays = new Date(birthday.replace(/-/g, "/"));
const Month = birthdays.getMonth() + 1;
let MonthDate;
const DayDate = birthdays.getDate();
let Day;
if (Month < 10) MonthDate = "0" + Month;
else MonthDate = Month;
if (DayDate < 10) Day = "0" + DayDate;
else Day = DayDate;
sex = org_gender % 2 === 1 ? "1" : "0";
date = birthdays.getFullYear() + "-" + MonthDate + "-" + Day;
}
return { sex, date };
}
/**
* @description 获取select中指定项组成的数组
* @param {Array} list 获取的数组
* @param {Array} value 获取的值
* @param {string?} idKey 获取的id
* @return {Array} list中指定项组成的数组
**/
export function getSelectAppointItemList(list, value, idKey = "id") {
return list.filter((item) => value.includes(item[idKey]));
}
/**
* @description json转换为树形结构
* @param {Array} json 需要转换的json
* @param {string} idStr id字段
* @param {string} pidStr 父级id字段
* @param {string} childrenStr 子级字段
* @return {Array} 转换完的树形结构
**/
export function listTransTree(json, idStr, pidStr, childrenStr) {
const r = [];
const hash = {};
const id = idStr;
const pid = pidStr;
const children = childrenStr;
let i = 0;
let j = 0;
const len = json.length;
for (; i < len; i++) {
hash[json[i][id]] = json[i];
}
for (; j < len; j++) {
const aVal = json[j];
const hashVP = hash[aVal[pid]];
if (hashVP) {
!hashVP[children] && (hashVP[children] = []);
hashVP[children].push(aVal);
} else {
r.push(aVal);
}
}
return r;
}
/**
* @description 将值转换为"是"/"否"显示文本
* @param {any} value 需要转换的值
* @param {Object} options 配置选项
* @param {string} options.yesText 真值时显示的文本默认为"是"
* @param {string} options.noText 假值时显示的文本默认为"否"
* @param {string|number} options.yesValue 判断为真的值默认为"1"
* @return {string} 转换后的显示文本
**/
export function isEmptyToWhether(value, options = {}) {
const { yesText = "是", noText = "否", yesValue = "1" } = options;
return !isEmpty(value)
? value.toString() === yesValue.toString()
? yesText
: noText
: "";
}
/**
* @description 计算表格中需要合并的行信息
* @param {Array} data 表格数据数组
* @param {string} field 用于比较的字段名相同值的行需要合并
* @param {Number} rowIndex 当前行索引
* @returns {Object} 包含rowspan和colspan属性的对象用于表格单元格合并
* - rowspan {number} 合并行数
* - colspan {number} 合并列数
*/
export function getRowSpans(data, field, rowIndex) {
if (!Array.isArray(data) || data.length === 0 || rowIndex < 0) {
return { rowspan: 1, colspan: 1 };
}
if (data.length === 1) {
return { rowspan: 1, colspan: 1 };
}
let currentSpanCount = 1;
let currentSpanIndex = 0;
for (let i = 1; i < data.length; i++) {
const currentValue = data[i][field];
const previousValue = data[i - 1][field];
if (currentValue === previousValue) {
currentSpanCount++;
if (i === rowIndex) {
return { rowspan: 0, colspan: 0 };
}
} else {
if (currentSpanIndex === rowIndex) {
return { rowspan: currentSpanCount, colspan: 1 };
}
currentSpanIndex = i;
currentSpanCount = 1;
}
}
if (currentSpanIndex === rowIndex) {
return { rowspan: currentSpanCount, colspan: 1 };
}
return { rowspan: 1, colspan: 1 };
}
/**
* @description 生成指定长度的guid
* @param {number} len 生成的guid长度
* @return {string} 生成的guid
**/
export function createGuid(len = 32) {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
let result = "";
for (let i = 0; i < len; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
/**
* @description 获取文件路径前缀地址
* @return {string} 文件路径前缀地址
**/
export function getFileUrl() {
return import.meta.env.VITE_FILE_URL;
}
export function getBaseUrl() {
return import.meta.env[import.meta.env.DEV ? "VITE_PROXY" : "VITE_BASE_URL"];
}
export function getWebUrl() {
return window.location.origin + window.location.pathname + "#";
}