合并 vue与cloud vue3 前端项目

This commit is contained in:
疯狂的狮子li
2023-03-15 15:59:21 +08:00
commit d68654327a
250 changed files with 18180 additions and 0 deletions

View File

@ -0,0 +1,66 @@
<template>
<el-breadcrumb class="app-breadcrumb" separator="/">
<transition-group name="breadcrumb">
<el-breadcrumb-item v-for="(item,index) in levelList" :key="item.path">
<span v-if="item.redirect === 'noRedirect' || index == levelList.length - 1" class="no-redirect">{{ item.meta.title }}</span>
<a v-else @click.prevent="handleLink(item)">{{ item.meta.title }}</a>
</el-breadcrumb-item>
</transition-group>
</el-breadcrumb>
</template>
<script setup>
const route = useRoute();
const router = useRouter();
const levelList = ref([])
function getBreadcrumb() {
// only show routes with meta.title
let matched = route.matched.filter(item => item.meta && item.meta.title);
const first = matched[0]
// 判断是否为首页
if (!isDashboard(first)) {
matched = [{ path: '/index', meta: { title: '首页' } }].concat(matched)
}
levelList.value = matched.filter(item => item.meta && item.meta.title && item.meta.breadcrumb !== false)
}
function isDashboard(route) {
const name = route && route.name
if (!name) {
return false
}
return name.trim() === 'Index'
}
function handleLink(item) {
const { redirect, path } = item
if (redirect) {
router.push(redirect)
return
}
router.push(path)
}
watchEffect(() => {
// if you go to the redirect page, do not update the breadcrumbs
if (route.path.startsWith('/redirect/')) {
return
}
getBreadcrumb()
})
getBreadcrumb();
</script>
<style lang='scss' scoped>
.app-breadcrumb.el-breadcrumb {
display: inline-block;
font-size: 14px;
line-height: 50px;
margin-left: 8px;
.no-redirect {
color: #97a8be;
cursor: text;
}
}
</style>

View File

@ -0,0 +1,49 @@
<template>
<div>
<template v-for="(item, index) in options">
<template v-if="values.includes(item.value)">
<span
v-if="item.elTagType == 'default' || item.elTagType == ''"
:key="item.value"
:index="index"
:class="item.elTagClass"
>{{ item.label }}</span>
<el-tag
v-else
:disable-transitions="true"
:key="item.value + ''"
:index="index"
:type="item.elTagType === 'primary' ? '' : item.elTagType"
:class="item.elTagClass"
>{{ item.label }}</el-tag>
</template>
</template>
</div>
</template>
<script setup>
const props = defineProps({
// 数据
options: {
type: Array,
default: null,
},
// 当前的值
value: [Number, String, Array],
})
const values = computed(() => {
if (props.value !== null && typeof props.value !== 'undefined') {
return Array.isArray(props.value) ? props.value : [String(props.value)];
} else {
return [];
}
})
</script>
<style scoped>
.el-tag + .el-tag {
margin-left: 10px;
}
</style>

View File

@ -0,0 +1,245 @@
<template>
<div>
<el-upload
:action="uploadUrl"
:before-upload="handleBeforeUpload"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
class="editor-img-uploader"
name="file"
:show-file-list="false"
:headers="headers"
style="display: none"
ref="uploadRef"
v-if="type == 'url'"
>
</el-upload>
<div class="editor">
<quill-editor
ref="myQuillEditor"
v-model:content="content"
contentType="html"
@textChange="(e) => $emit('update:modelValue', content)"
:options="options"
:style="styles"
/>
</div>
</div>
</template>
<script setup>
import { QuillEditor, Quill } from '@vueup/vue-quill';
import '@vueup/vue-quill/dist/vue-quill.snow.css';
import { getToken } from "@/utils/auth";
const props = defineProps({
/* 编辑器的内容 */
modelValue: {
type: String,
},
/* 高度 */
height: {
type: Number,
default: null,
},
/* 最小高度 */
minHeight: {
type: Number,
default: null,
},
/* 只读 */
readOnly: {
type: Boolean,
default: false,
},
/* 上传文件大小限制(MB) */
fileSize: {
type: Number,
default: 5,
},
/* 类型base64格式、url格式 */
type: {
type: String,
default: "url",
}
});
const { proxy } = getCurrentInstance();
// 上传的图片服务器地址
const uploadUrl = ref(import.meta.env.VITE_APP_BASE_API + "/system/oss/upload");
const headers = ref({ Authorization: "Bearer " + getToken() });
const myQuillEditor = ref();
const options = ref({
theme: "snow",
bounds: document.body,
debug: "warn",
modules: {
// 工具栏配置
toolbar: {
container: [
["bold", "italic", "underline", "strike"], // 加粗 斜体 下划线 删除线
["blockquote", "code-block"], // 引用 代码块
[{ list: "ordered" }, { list: "bullet"} ], // 有序、无序列表
[{ indent: "-1" }, { indent: "+1" }], // 缩进
[{ size: ["small", false, "large", "huge"] }], // 字体大小
[{ header: [1, 2, 3, 4, 5, 6, false] }], // 标题
[{ color: [] }, { background: [] }], // 字体颜色、字体背景颜色
[{ align: [] }], // 对齐方式
["clean"], // 清除文本格式
["link", "image", "video"] // 链接、图片、视频
],
handlers: {
image: function (value) {
if (value) {
// 调用element图片上传
document.querySelector(".editor-img-uploader>.el-upload").click();
} else {
Quill.format("image", true);
}
},
},
}
},
placeholder: '请输入内容',
readOnly: props.readOnly,
});
const styles = computed(() => {
let style = {};
if (props.minHeight) {
style.minHeight = `${props.minHeight}px`;
}
if (props.height) {
style.height = `${props.height}px`;
}
return style;
})
const content = ref("");
watch(() => props.modelValue, (v) => {
if (v !== content) {
content.value = v === undefined ? "<p></p>" : v;
}
}, { immediate: true });
// 图片上传成功返回图片地址
function handleUploadSuccess(res, file) {
// 获取富文本实例
let quill = toRaw(myQuillEditor.value).getQuill();
// 如果上传成功
if (res.code == 200) {
// 获取光标位置
let length = quill.selection.savedRange.index;
// 插入图片res为服务器返回的图片链接地址
quill.insertEmbed(length, "image", res.data.url);
// 调整光标到最后
quill.setSelection(length + 1);
proxy.$modal.closeLoading();
} else {
proxy.$modal.loading(res.msg);
proxy.$modal.closeLoading();
}
}
// 图片上传前拦截
function handleBeforeUpload(file) {
// 校检文件大小
if (props.fileSize) {
const isLt = file.size / 1024 / 1024 < props.fileSize;
if (!isLt) {
proxy.$modal.msgError(`上传文件大小不能超过 ${props.fileSize} MB!`);
return false;
}
}
proxy.$modal.loading("正在上传文件,请稍候...");
return true;
}
// 图片失败拦截
function handleUploadError(err) {
proxy.$modal.msgError("上传文件失败");
}
</script>
<style>
.editor, .ql-toolbar {
white-space: pre-wrap !important;
line-height: normal !important;
}
.quill-img {
display: none;
}
.ql-snow .ql-tooltip[data-mode="link"]::before {
content: "请输入链接地址:";
}
.ql-snow .ql-tooltip.ql-editing a.ql-action::after {
border-right: 0px;
content: "保存";
padding-right: 0px;
}
.ql-snow .ql-tooltip[data-mode="video"]::before {
content: "请输入视频地址:";
}
.ql-snow .ql-picker.ql-size .ql-picker-label::before,
.ql-snow .ql-picker.ql-size .ql-picker-item::before {
content: "14px";
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="small"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="small"]::before {
content: "10px";
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="large"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="large"]::before {
content: "18px";
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="huge"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="huge"]::before {
content: "32px";
}
.ql-snow .ql-picker.ql-header .ql-picker-label::before,
.ql-snow .ql-picker.ql-header .ql-picker-item::before {
content: "文本";
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="1"]::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="1"]::before {
content: "标题1";
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="2"]::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="2"]::before {
content: "标题2";
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="3"]::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="3"]::before {
content: "标题3";
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="4"]::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="4"]::before {
content: "标题4";
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="5"]::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="5"]::before {
content: "标题5";
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="6"]::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="6"]::before {
content: "标题6";
}
.ql-snow .ql-picker.ql-font .ql-picker-label::before,
.ql-snow .ql-picker.ql-font .ql-picker-item::before {
content: "标准字体";
}
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="serif"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="serif"]::before {
content: "衬线字体";
}
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="monospace"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="monospace"]::before {
content: "等宽字体";
}
</style>

View File

@ -0,0 +1,218 @@
<template>
<div class="upload-file">
<el-upload
multiple
:action="uploadFileUrl"
:before-upload="handleBeforeUpload"
:file-list="fileList"
:limit="limit"
:on-error="handleUploadError"
:on-exceed="handleExceed"
:on-success="handleUploadSuccess"
:show-file-list="false"
:headers="headers"
class="upload-file-uploader"
ref="fileUpload"
>
<!-- 上传按钮 -->
<el-button type="primary">选取文件</el-button>
</el-upload>
<!-- 上传提示 -->
<div class="el-upload__tip" v-if="showTip">
请上传
<template v-if="fileSize"> 大小不超过 <b style="color: #f56c6c">{{ fileSize }}MB</b> </template>
<template v-if="fileType"> 格式为 <b style="color: #f56c6c">{{ fileType.join("/") }}</b> </template>
的文件
</div>
<!-- 文件列表 -->
<transition-group class="upload-file-list el-upload-list el-upload-list--text" name="el-fade-in-linear" tag="ul">
<li :key="file.uid" class="el-upload-list__item ele-upload-list__item-content" v-for="(file, index) in fileList">
<el-link :href="`${file.url}`" :underline="false" target="_blank">
<span class="el-icon-document"> {{ getFileName(file.name) }} </span>
</el-link>
<div class="ele-upload-list__item-content-action">
<el-link :underline="false" @click="handleDelete(index)" type="danger">删除</el-link>
</div>
</li>
</transition-group>
</div>
</template>
<script setup>
import { getToken } from "@/utils/auth";
import { listByIds, delOss } from "@/api/system/oss";
const props = defineProps({
modelValue: [String, Object, Array],
// 数量限制
limit: {
type: Number,
default: 5,
},
// 大小限制(MB)
fileSize: {
type: Number,
default: 5,
},
// 文件类型, 例如['png', 'jpg', 'jpeg']
fileType: {
type: Array,
default: () => ["doc", "xls", "ppt", "txt", "pdf"],
},
// 是否显示提示
isShowTip: {
type: Boolean,
default: true
}
});
const { proxy } = getCurrentInstance();
const emit = defineEmits();
const number = ref(0);
const uploadList = ref([]);
const baseUrl = import.meta.env.VITE_APP_BASE_API;
const uploadFileUrl = ref(baseUrl + "/system/oss/upload"); // 上传文件服务器地址
const headers = ref({ Authorization: "Bearer " + getToken() });
const fileList = ref([]);
const showTip = computed(
() => props.isShowTip && (props.fileType || props.fileSize)
);
watch(() => props.modelValue, async val => {
if (val) {
let temp = 1;
// 首先将值转为数组
let list;
if (Array.isArray(val)) {
list = val;
} else {
await listByIds(val).then(res => {
list = res.data.map(oss => {
oss = { name: oss.originalName, url: oss.url, ossId: oss.ossId };
return oss;
});
})
}
// 然后将数组转为对象数组
fileList.value = list.map(item => {
item = {name: item.name, url: item.url, ossId: item.ossId};
item.uid = item.uid || new Date().getTime() + temp++;
return item;
});
} else {
fileList.value = [];
return [];
}
},{ deep: true, immediate: true });
// 上传前校检格式和大小
function handleBeforeUpload(file) {
// 校检文件类型
if (props.fileType.length) {
const fileName = file.name.split('.');
const fileExt = fileName[fileName.length - 1];
const isTypeOk = props.fileType.indexOf(fileExt) >= 0;
if (!isTypeOk) {
proxy.$modal.msgError(`文件格式不正确, 请上传${props.fileType.join("/")}格式文件!`);
return false;
}
}
// 校检文件大小
if (props.fileSize) {
const isLt = file.size / 1024 / 1024 < props.fileSize;
if (!isLt) {
proxy.$modal.msgError(`上传文件大小不能超过 ${props.fileSize} MB!`);
return false;
}
}
proxy.$modal.loading("正在上传文件,请稍候...");
number.value++;
return true;
}
// 文件个数超出
function handleExceed() {
proxy.$modal.msgError(`上传文件数量不能超过 ${props.limit} 个!`);
}
// 上传失败
function handleUploadError(err) {
proxy.$modal.msgError("上传文件失败");
}
// 上传成功回调
function handleUploadSuccess(res, file) {
if (res.code === 200) {
uploadList.value.push({ name: res.data.fileName, url: res.data.url, ossId: res.data.ossId });
uploadedSuccessfully();
} else {
number.value--;
proxy.$modal.closeLoading();
proxy.$modal.msgError(res.msg);
proxy.$refs.fileUpload.handleRemove(file);
uploadedSuccessfully();
}
}
// 删除文件
function handleDelete(index) {
let ossId = fileList.value[index].ossId;
delOss(ossId);
fileList.value.splice(index, 1);
emit("update:modelValue", listToString(fileList.value));
}
// 上传结束处理
function uploadedSuccessfully() {
if (number.value > 0 && uploadList.value.length === number.value) {
fileList.value = fileList.value.filter(f => f.url !== undefined).concat(uploadList.value);
uploadList.value = [];
number.value = 0;
emit("update:modelValue", listToString(fileList.value));
proxy.$modal.closeLoading();
}
}
// 获取文件名称
function getFileName(name) {
// 如果是url那么取最后的名字 如果不是直接返回
if (name.lastIndexOf("/") > -1) {
return name.slice(name.lastIndexOf("/") + 1);
} else {
return name;
}
}
// 对象转成指定字符串分隔
function listToString(list, separator) {
let strs = "";
separator = separator || ",";
for (let i in list) {
if(list[i].ossId) {
strs += list[i].ossId + separator;
}
}
return strs != "" ? strs.substr(0, strs.length - 1) : "";
}
</script>
<style scoped lang="scss">
.upload-file-uploader {
margin-bottom: 5px;
}
.upload-file-list .el-upload-list__item {
border: 1px solid #e4e7ed;
line-height: 2;
margin-bottom: 10px;
position: relative;
}
.upload-file-list .ele-upload-list__item-content {
display: flex;
justify-content: space-between;
align-items: center;
color: inherit;
}
.ele-upload-list__item-content-action .el-link {
margin-right: 10px;
}
</style>

View File

@ -0,0 +1,41 @@
<template>
<div style="padding: 0 15px;" @click="toggleClick">
<svg
:class="{'is-active':isActive}"
class="hamburger"
viewBox="0 0 1024 1024"
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
>
<path d="M408 442h480c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H408c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8zm-8 204c0 4.4 3.6 8 8 8h480c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H408c-4.4 0-8 3.6-8 8v56zm504-486H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zm0 632H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zM142.4 642.1L298.7 519a8.84 8.84 0 0 0 0-13.9L142.4 381.9c-5.8-4.6-14.4-.5-14.4 6.9v246.3a8.9 8.9 0 0 0 14.4 7z" />
</svg>
</div>
</template>
<script setup>
defineProps({
isActive: {
type: Boolean,
default: false
}
})
const emit = defineEmits()
const toggleClick = () => {
emit('toggleClick');
}
</script>
<style scoped>
.hamburger {
display: inline-block;
vertical-align: middle;
width: 20px;
height: 20px;
}
.hamburger.is-active {
transform: rotate(180deg);
}
</style>

View File

@ -0,0 +1,179 @@
<template>
<div :class="{ 'show': show }" class="header-search">
<svg-icon class-name="search-icon" icon-class="search" @click.stop="click" />
<el-select
ref="headerSearchSelectRef"
v-model="search"
:remote-method="querySearch"
filterable
default-first-option
remote
placeholder="Search"
class="header-search-select"
@change="change"
>
<el-option v-for="option in options" :key="option.item.path" :value="option.item" :label="option.item.title.join(' > ')" />
</el-select>
</div>
</template>
<script setup>
import Fuse from 'fuse.js'
import { getNormalPath } from '@/utils/ruoyi'
import { isHttp } from '@/utils/validate'
import usePermissionStore from '@/store/modules/permission'
const search = ref('');
const options = ref([]);
const searchPool = ref([]);
const show = ref(false);
const fuse = ref(undefined);
const headerSearchSelectRef = ref(null);
const router = useRouter();
const routes = computed(() => usePermissionStore().routes);
function click() {
show.value = !show.value
if (show.value) {
headerSearchSelectRef.value && headerSearchSelectRef.value.focus()
}
};
function close() {
headerSearchSelectRef.value && headerSearchSelectRef.value.blur()
options.value = []
show.value = false
}
function change(val) {
const path = val.path;
if (isHttp(path)) {
// http(s):// 路径新窗口打开
const pindex = path.indexOf("http");
window.open(path.substr(pindex, path.length), "_blank");
} else {
router.push(path)
}
search.value = ''
options.value = []
nextTick(() => {
show.value = false
})
}
function initFuse(list) {
fuse.value = new Fuse(list, {
shouldSort: true,
threshold: 0.4,
location: 0,
distance: 100,
minMatchCharLength: 1,
keys: [{
name: 'title',
weight: 0.7
}, {
name: 'path',
weight: 0.3
}]
})
}
// Filter out the routes that can be displayed in the sidebar
// And generate the internationalized title
function generateRoutes(routes, basePath = '', prefixTitle = []) {
let res = []
for (const r of routes) {
// skip hidden router
if (r.hidden) { continue }
const p = r.path.length > 0 && r.path[0] === '/' ? r.path : '/' + r.path;
const data = {
path: !isHttp(r.path) ? getNormalPath(basePath + p) : r.path,
title: [...prefixTitle]
}
if (r.meta && r.meta.title) {
data.title = [...data.title, r.meta.title]
if (r.redirect !== 'noRedirect') {
// only push the routes with title
// special case: need to exclude parent router without redirect
res.push(data)
}
}
// recursive child routes
if (r.children) {
const tempRoutes = generateRoutes(r.children, data.path, data.title)
if (tempRoutes.length >= 1) {
res = [...res, ...tempRoutes]
}
}
}
return res
}
function querySearch(query) {
if (query !== '') {
options.value = fuse.value.search(query)
} else {
options.value = []
}
}
onMounted(() => {
searchPool.value = generateRoutes(routes.value);
})
watchEffect(() => {
searchPool.value = generateRoutes(routes.value)
})
watch(show, (value) => {
if (value) {
document.body.addEventListener('click', close)
} else {
document.body.removeEventListener('click', close)
}
})
watch(searchPool, (list) => {
initFuse(list)
})
</script>
<style lang='scss' scoped>
.header-search {
font-size: 0 !important;
.search-icon {
cursor: pointer;
font-size: 18px;
vertical-align: middle;
}
.header-search-select {
font-size: 18px;
transition: width 0.2s;
width: 0;
overflow: hidden;
background: transparent;
border-radius: 0;
display: inline-block;
vertical-align: middle;
:deep(.el-input__inner) {
border-radius: 0;
border: 0;
padding-left: 0;
padding-right: 0;
box-shadow: none !important;
border-bottom: 1px solid #d9d9d9;
vertical-align: middle;
}
}
&.show {
.header-search-select {
width: 210px;
margin-left: 10px;
}
}
}
</style>

View File

@ -0,0 +1,74 @@
<template>
<div class="icon-body">
<el-input
v-model="iconName"
style="position: relative;"
clearable
placeholder="请输入图标名称"
@clear="filterIcons"
@input="filterIcons"
>
<template #suffix><i class="el-icon-search el-input__icon" /></template>
</el-input>
<div class="icon-list">
<div v-for="(item, index) in iconList" :key="index" @click="selectedIcon(item)">
<svg-icon :icon-class="item" style="height: 30px;width: 16px;" />
<span>{{ item }}</span>
</div>
</div>
</div>
</template>
<script setup>
import icons from './requireIcons'
const iconName = ref('');
const iconList = ref(icons);
const emit = defineEmits(['selected']);
function filterIcons() {
iconList.value = icons
if (iconName.value) {
iconList.value = icons.filter(item => item.indexOf(iconName.value) !== -1)
}
}
function selectedIcon(name) {
emit('selected', name)
document.body.click()
}
function reset() {
iconName.value = ''
iconList.value = icons
}
defineExpose({
reset
})
</script>
<style lang='scss' scoped>
.icon-body {
width: 100%;
padding: 10px;
.icon-list {
height: 200px;
overflow-y: scroll;
div {
height: 30px;
line-height: 30px;
margin-bottom: -5px;
cursor: pointer;
width: 33%;
float: left;
}
span {
display: inline-block;
vertical-align: -0.15em;
fill: currentColor;
overflow: hidden;
}
}
}
</style>

View File

@ -0,0 +1,8 @@
let icons = []
const modules = import.meta.glob('./../../assets/icons/svg/*.svg');
for (const path in modules) {
const p = path.split('assets/icons/svg/')[1].split('.svg')[0];
icons.push(p);
}
export default icons

View File

@ -0,0 +1,85 @@
<template>
<el-image
:src="`${realSrc}`"
fit="cover"
:style="`width:${realWidth};height:${realHeight};`"
:preview-src-list="realSrcList"
preview-teleported
>
<template #error>
<div class="image-slot">
<el-icon><picture-filled /></el-icon>
</div>
</template>
</el-image>
</template>
<script setup>
const props = defineProps({
src: {
type: String,
default: ""
},
width: {
type: [Number, String],
default: ""
},
height: {
type: [Number, String],
default: ""
}
});
const realSrc = computed(() => {
if (!props.src) {
return;
}
let real_src = props.src.split(",")[0];
return real_src;
});
const realSrcList = computed(() => {
if (!props.src) {
return;
}
let real_src_list = props.src.split(",");
let srcList = [];
real_src_list.forEach(item => {
return srcList.push(item);
});
return srcList;
});
const realWidth = computed(() =>
typeof props.width == "string" ? props.width : `${props.width}px`
);
const realHeight = computed(() =>
typeof props.height == "string" ? props.height : `${props.height}px`
);
</script>
<style lang="scss" scoped>
.el-image {
border-radius: 5px;
background-color: #ebeef5;
box-shadow: 0 0 5px 1px #ccc;
:deep(.el-image__inner) {
transition: all 0.3s;
cursor: pointer;
&:hover {
transform: scale(1.2);
}
}
:deep(.image-slot) {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
color: #909399;
font-size: 30px;
}
}
</style>

View File

@ -0,0 +1,223 @@
<template>
<div class="component-upload-image">
<el-upload
multiple
:action="uploadImgUrl"
list-type="picture-card"
:on-success="handleUploadSuccess"
:before-upload="handleBeforeUpload"
:limit="limit"
:on-error="handleUploadError"
:on-exceed="handleExceed"
ref="imageUpload"
:before-remove="handleDelete"
:show-file-list="true"
:headers="headers"
:file-list="fileList"
:on-preview="handlePictureCardPreview"
:class="{ hide: fileList.length >= limit }"
>
<el-icon class="avatar-uploader-icon"><plus /></el-icon>
</el-upload>
<!-- 上传提示 -->
<div class="el-upload__tip" v-if="showTip">
请上传
<template v-if="fileSize">
大小不超过 <b style="color: #f56c6c">{{ fileSize }}MB</b>
</template>
<template v-if="fileType">
格式为 <b style="color: #f56c6c">{{ fileType.join("/") }}</b>
</template>
的文件
</div>
<el-dialog
v-model="dialogVisible"
title="预览"
width="800px"
append-to-body
>
<img
:src="dialogImageUrl"
style="display: block; max-width: 100%; margin: 0 auto"
/>
</el-dialog>
</div>
</template>
<script setup>
import { getToken } from "@/utils/auth";
import { listByIds, delOss } from "@/api/system/oss";
const props = defineProps({
modelValue: [String, Object, Array],
// 图片数量限制
limit: {
type: Number,
default: 5,
},
// 大小限制(MB)
fileSize: {
type: Number,
default: 5,
},
// 文件类型, 例如['png', 'jpg', 'jpeg']
fileType: {
type: Array,
default: () => ["png", "jpg", "jpeg"],
},
// 是否显示提示
isShowTip: {
type: Boolean,
default: true
},
});
const { proxy } = getCurrentInstance();
const emit = defineEmits();
const number = ref(0);
const uploadList = ref([]);
const dialogImageUrl = ref("");
const dialogVisible = ref(false);
const baseUrl = import.meta.env.VITE_APP_BASE_API;
const uploadImgUrl = ref(baseUrl + "/system/oss/upload"); // 上传的图片服务器地址
const headers = ref({ Authorization: "Bearer " + getToken() });
const fileList = ref([]);
const showTip = computed(
() => props.isShowTip && (props.fileType || props.fileSize)
);
watch(() => props.modelValue, async val => {
if (val) {
// 首先将值转为数组
let list;
if (Array.isArray(val)) {
list = val;
} else {
await listByIds(val).then(res => {
list = res.data;
})
}
// 然后将数组转为对象数组
fileList.value = list.map(item => {
// 字符串回显处理 如果此处存的是url可直接回显 如果存的是id需要调用接口查出来
if (typeof item === "string") {
item = { name: item, url: item };
} else {
// 此处name使用ossId 防止删除出现重名
item = { name: item.ossId, url: item.url, ossId: item.ossId };
}
return item;
});
} else {
fileList.value = [];
return [];
}
},{ deep: true, immediate: true });
// 上传前loading加载
function handleBeforeUpload(file) {
let isImg = false;
if (props.fileType.length) {
let fileExtension = "";
if (file.name.lastIndexOf(".") > -1) {
fileExtension = file.name.slice(file.name.lastIndexOf(".") + 1);
}
isImg = props.fileType.some((type) => {
if (file.type.indexOf(type) > -1) return true;
if (fileExtension && fileExtension.indexOf(type) > -1) return true;
return false;
});
} else {
isImg = file.type.indexOf("image") > -1;
}
if (!isImg) {
proxy.$modal.msgError(
`文件格式不正确, 请上传${props.fileType.join("/")}图片格式文件!`
);
return false;
}
if (props.fileSize) {
const isLt = file.size / 1024 / 1024 < props.fileSize;
if (!isLt) {
proxy.$modal.msgError(`上传头像图片大小不能超过 ${props.fileSize} MB!`);
return false;
}
}
proxy.$modal.loading("正在上传图片,请稍候...");
number.value++;
}
// 文件个数超出
function handleExceed() {
proxy.$modal.msgError(`上传文件数量不能超过 ${props.limit} 个!`);
}
// 上传成功回调
function handleUploadSuccess(res, file) {
if (res.code === 200) {
uploadList.value.push({ name: res.data.fileName, url: res.data.url, ossId: res.data.ossId });
uploadedSuccessfully();
} else {
number.value--;
proxy.$modal.closeLoading();
proxy.$modal.msgError(res.msg);
proxy.$refs.imageUpload.handleRemove(file);
uploadedSuccessfully();
}
}
// 删除图片
function handleDelete(file) {
const findex = fileList.value.map(f => f.name).indexOf(file.name);
if (findex > -1 && uploadList.value.length === number.value) {
let ossId = fileList.value[findex].ossId;
delOss(ossId);
fileList.value.splice(findex, 1);
emit("update:modelValue", listToString(fileList.value));
return false;
}
}
// 上传结束处理
function uploadedSuccessfully() {
if (number.value > 0 && uploadList.value.length === number.value) {
fileList.value = fileList.value.filter(f => f.url !== undefined).concat(uploadList.value);
uploadList.value = [];
number.value = 0;
emit("update:modelValue", listToString(fileList.value));
proxy.$modal.closeLoading();
}
}
// 上传失败
function handleUploadError(res) {
proxy.$modal.msgError("上传图片失败");
proxy.$modal.closeLoading();
}
// 预览
function handlePictureCardPreview(file) {
dialogImageUrl.value = file.url;
dialogVisible.value = true;
}
// 对象转成指定字符串分隔
function listToString(list, separator) {
let strs = "";
separator = separator || ",";
for (let i in list) {
if(undefined !== list[i].ossId && list[i].url.indexOf("blob:") !== 0) {
strs += list[i].ossId + separator;
}
}
return strs != "" ? strs.substr(0, strs.length - 1) : "";
}
</script>
<style scoped lang="scss">
// .el-upload--picture-card 控制加号部分
:deep(.hide .el-upload--picture-card) {
display: none;
}
</style>

View File

@ -0,0 +1,105 @@
<template>
<div :class="{ 'hidden': hidden }" class="pagination-container">
<el-pagination
:background="background"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:layout="layout"
:page-sizes="pageSizes"
:pager-count="pagerCount"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</template>
<script setup>
import { scrollTo } from '@/utils/scroll-to'
const props = defineProps({
total: {
required: true,
type: Number
},
page: {
type: Number,
default: 1
},
limit: {
type: Number,
default: 20
},
pageSizes: {
type: Array,
default() {
return [10, 20, 30, 50]
}
},
// 移动端页码按钮的数量端默认值5
pagerCount: {
type: Number,
default: document.body.clientWidth < 992 ? 5 : 7
},
layout: {
type: String,
default: 'total, sizes, prev, pager, next, jumper'
},
background: {
type: Boolean,
default: true
},
autoScroll: {
type: Boolean,
default: true
},
hidden: {
type: Boolean,
default: false
}
})
const emit = defineEmits();
const currentPage = computed({
get() {
return props.page
},
set(val) {
emit('update:page', val)
}
})
const pageSize = computed({
get() {
return props.limit
},
set(val){
emit('update:limit', val)
}
})
function handleSizeChange(val) {
if (currentPage.value * val > props.total) {
currentPage.value = 1
}
emit('pagination', { page: currentPage.value, limit: val })
if (props.autoScroll) {
scrollTo(0, 800)
}
}
function handleCurrentChange(val) {
emit('pagination', { page: val, limit: pageSize.value })
if (props.autoScroll) {
scrollTo(0, 800)
}
}
</script>
<style scoped>
.pagination-container {
background: #fff;
padding: 32px 16px;
}
.pagination-container.hidden {
display: none;
}
</style>

View File

@ -0,0 +1,3 @@
<template >
<router-view />
</template>

View File

@ -0,0 +1,105 @@
<template>
<div class="top-right-btn" :style="style">
<el-row>
<el-tooltip class="item" effect="dark" :content="showSearch ? '隐藏搜索' : '显示搜索'" placement="top" v-if="search">
<el-button circle icon="Search" @click="toggleSearch()" />
</el-tooltip>
<el-tooltip class="item" effect="dark" content="刷新" placement="top">
<el-button circle icon="Refresh" @click="refresh()" />
</el-tooltip>
<el-tooltip class="item" effect="dark" content="显隐列" placement="top" v-if="columns">
<el-button circle icon="Menu" @click="showColumn()" />
</el-tooltip>
</el-row>
<el-dialog :title="title" v-model="open" append-to-body>
<el-transfer
:titles="['显示', '隐藏']"
v-model="value"
:data="columns"
@change="dataChange"
></el-transfer>
</el-dialog>
</div>
</template>
<script setup>
const props = defineProps({
showSearch: {
type: Boolean,
default: true,
},
columns: {
type: Array,
},
search: {
type: Boolean,
default: true,
},
gutter: {
type: Number,
default: 10,
},
})
const emits = defineEmits(['update:showSearch', 'queryTable']);
// 显隐数据
const value = ref([]);
// 弹出层标题
const title = ref("显示/隐藏");
// 是否显示弹出层
const open = ref(false);
const style = computed(() => {
const ret = {};
if (props.gutter) {
ret.marginRight = `${props.gutter / 2}px`;
}
return ret;
});
// 搜索
function toggleSearch() {
emits("update:showSearch", !props.showSearch);
}
// 刷新
function refresh() {
emits("queryTable");
}
// 右侧列表元素变化
function dataChange(data) {
for (let item in props.columns) {
const key = props.columns[item].key;
props.columns[item].visible = !data.includes(key);
}
}
// 打开显隐列dialog
function showColumn() {
open.value = true;
}
// 显隐列初始默认隐藏列
for (let item in props.columns) {
if (props.columns[item].visible === false) {
value.value.push(parseInt(item));
}
}
</script>
<style lang='scss' scoped>
:deep(.el-transfer__button) {
border-radius: 50%;
display: block;
margin-left: 0px;
}
:deep(.el-transfer__button:first-child) {
margin-bottom: 10px;
}
.my-el-transfer {
text-align: center;
}
</style>

View File

@ -0,0 +1,13 @@
<template>
<div>
<svg-icon icon-class="question" @click="goto" />
</div>
</template>
<script setup>
const url = ref('https://gitee.com/JavaLionLi/RuoYi-Vue-Plus/wikis/pages');
function goto() {
window.open(url.value)
}
</script>

View File

@ -0,0 +1,13 @@
<template>
<div>
<svg-icon icon-class="github" @click="goto" />
</div>
</template>
<script setup>
const url = ref('https://gitee.com/JavaLionLi/RuoYi-Vue-Plus');
function goto() {
window.open(url.value)
}
</script>

View File

@ -0,0 +1,22 @@
<template>
<div>
<svg-icon :icon-class="isFullscreen ? 'exit-fullscreen' : 'fullscreen'" @click="toggle" />
</div>
</template>
<script setup>
import { useFullscreen } from '@vueuse/core'
const { isFullscreen, enter, exit, toggle } = useFullscreen();
</script>
<style lang='scss' scoped>
.screenfull-svg {
display: inline-block;
cursor: pointer;
fill: #5a5e66;
width: 20px;
height: 20px;
vertical-align: 10px;
}
</style>

View File

@ -0,0 +1,45 @@
<template>
<div>
<el-dropdown trigger="click" @command="handleSetSize">
<div class="size-icon--style">
<svg-icon class-name="size-icon" icon-class="size" />
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="item of sizeOptions" :key="item.value" :disabled="size === item.value" :command="item.value">
{{ item.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<script setup>
import useAppStore from "@/store/modules/app";
const appStore = useAppStore();
const size = computed(() => appStore.size);
const route = useRoute();
const router = useRouter();
const { proxy } = getCurrentInstance();
const sizeOptions = ref([
{ label: "较大", value: "large" },
{ label: "默认", value: "default" },
{ label: "稍小", value: "small" },
]);
function handleSetSize(size) {
proxy.$modal.loading("正在设置布局大小,请稍候...");
appStore.setSize(size);
setTimeout("window.location.reload()", 1000);
}
</script>
<style lang='scss' scoped>
.size-icon--style {
font-size: 18px;
line-height: 50px;
padding-right: 7px;
}
</style>

View File

@ -0,0 +1,53 @@
<template>
<svg :class="svgClass" aria-hidden="true">
<use :xlink:href="iconName" :fill="color" />
</svg>
</template>
<script>
export default defineComponent({
props: {
iconClass: {
type: String,
required: true
},
className: {
type: String,
default: ''
},
color: {
type: String,
default: ''
},
},
setup(props) {
return {
iconName: computed(() => `#icon-${props.iconClass}`),
svgClass: computed(() => {
if (props.className) {
return `svg-icon ${props.className}`
}
return 'svg-icon'
})
}
}
})
</script>
<style scope lang="scss">
.sub-el-icon,
.nav-icon {
display: inline-block;
font-size: 15px;
margin-right: 12px;
position: relative;
}
.svg-icon {
width: 1em;
height: 1em;
position: relative;
fill: currentColor;
vertical-align: -2px;
}
</style>

View File

@ -0,0 +1,10 @@
import * as components from '@element-plus/icons-vue'
export default {
install: (app) => {
for (const key in components) {
const componentConfig = components[key];
app.component(componentConfig.name, componentConfig);
}
},
};

View File

@ -0,0 +1,186 @@
<template>
<el-menu
:default-active="activeMenu"
mode="horizontal"
@select="handleSelect"
:ellipsis="false"
>
<template v-for="(item, index) in topMenus">
<el-menu-item :style="{'--theme': theme}" :index="item.path" :key="index" v-if="index < visibleNumber"
><svg-icon :icon-class="item.meta.icon" />
{{ item.meta.title }}</el-menu-item
>
</template>
<!-- 顶部菜单超出数量折叠 -->
<el-sub-menu :style="{'--theme': theme}" index="more" v-if="topMenus.length > visibleNumber">
<template #title>更多菜单</template>
<template v-for="(item, index) in topMenus">
<el-menu-item
:index="item.path"
:key="index"
v-if="index >= visibleNumber"
><svg-icon :icon-class="item.meta.icon" />
{{ item.meta.title }}</el-menu-item
>
</template>
</el-sub-menu>
</el-menu>
</template>
<script setup>
import { constantRoutes } from "@/router"
import { isHttp } from '@/utils/validate'
import useAppStore from '@/store/modules/app'
import useSettingsStore from '@/store/modules/settings'
import usePermissionStore from '@/store/modules/permission'
// 顶部栏初始数
const visibleNumber = ref(null);
// 当前激活菜单的 index
const currentIndex = ref(null);
// 隐藏侧边栏路由
const hideList = ['/index', '/user/profile'];
const appStore = useAppStore()
const settingsStore = useSettingsStore()
const permissionStore = usePermissionStore()
const route = useRoute();
const router = useRouter();
// 主题颜色
const theme = computed(() => settingsStore.theme);
// 所有的路由信息
const routers = computed(() => permissionStore.topbarRouters);
// 顶部显示菜单
const topMenus = computed(() => {
let topMenus = [];
routers.value.map((menu) => {
if (menu.hidden !== true) {
// 兼容顶部栏一级菜单内部跳转
if (menu.path === "/") {
topMenus.push(menu.children[0]);
} else {
topMenus.push(menu);
}
}
})
return topMenus;
})
// 设置子路由
const childrenMenus = computed(() => {
let childrenMenus = [];
routers.value.map((router) => {
for (let item in router.children) {
if (router.children[item].parentPath === undefined) {
if(router.path === "/") {
router.children[item].path = "/" + router.children[item].path;
} else {
if(!isHttp(router.children[item].path)) {
router.children[item].path = router.path + "/" + router.children[item].path;
}
}
router.children[item].parentPath = router.path;
}
childrenMenus.push(router.children[item]);
}
})
return constantRoutes.concat(childrenMenus);
})
// 默认激活的菜单
const activeMenu = computed(() => {
const path = route.path;
let activePath = path;
if (path !== undefined && path.lastIndexOf("/") > 0 && hideList.indexOf(path) === -1) {
const tmpPath = path.substring(1, path.length);
activePath = "/" + tmpPath.substring(0, tmpPath.indexOf("/"));
if (!route.meta.link) {
appStore.toggleSideBarHide(false);
}
} else if(!route.children) {
activePath = path;
appStore.toggleSideBarHide(true);
}
activeRoutes(activePath);
return activePath;
})
function setVisibleNumber() {
const width = document.body.getBoundingClientRect().width / 3;
visibleNumber.value = parseInt(width / 85);
}
function handleSelect(key, keyPath) {
currentIndex.value = key;
const route = routers.value.find(item => item.path === key);
if (isHttp(key)) {
// http(s):// 路径新窗口打开
window.open(key, "_blank");
} else if (!route || !route.children) {
// 没有子路由路径内部打开
router.push({ path: key });
appStore.toggleSideBarHide(true);
} else {
// 显示左侧联动菜单
activeRoutes(key);
appStore.toggleSideBarHide(false);
}
}
function activeRoutes(key) {
let routes = [];
if (childrenMenus.value && childrenMenus.value.length > 0) {
childrenMenus.value.map((item) => {
if (key == item.parentPath || (key == "index" && "" == item.path)) {
routes.push(item);
}
});
}
if(routes.length > 0) {
permissionStore.setSidebarRouters(routes);
} else {
appStore.toggleSideBarHide(true);
}
return routes;
}
onMounted(() => {
window.addEventListener('resize', setVisibleNumber)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', setVisibleNumber)
})
onMounted(() => {
setVisibleNumber()
})
</script>
<style lang="scss">
.topmenu-container.el-menu--horizontal > .el-menu-item {
float: left;
height: 50px !important;
line-height: 50px !important;
color: #999093 !important;
padding: 0 5px !important;
margin: 0 10px !important;
}
.topmenu-container.el-menu--horizontal > .el-menu-item.is-active, .el-menu--horizontal > .el-sub-menu.is-active .el-submenu__title {
border-bottom: 2px solid #{'var(--theme)'} !important;
color: #303133;
}
/* sub-menu item */
.topmenu-container.el-menu--horizontal > .el-sub-menu .el-sub-menu__title {
float: left;
height: 50px !important;
line-height: 50px !important;
color: #999093 !important;
padding: 0 5px !important;
margin: 0 10px !important;
}
</style>

View File

@ -0,0 +1,156 @@
<template>
<div class="el-tree-select">
<el-select
style="width: 100%"
v-model="valueId"
ref="treeSelect"
:filterable="true"
:clearable="true"
@clear="clearHandle"
:filter-method="selectFilterData"
:placeholder="placeholder"
>
<el-option :value="valueId" :label="valueTitle">
<el-tree
id="tree-option"
ref="selectTree"
:accordion="accordion"
:data="options"
:props="objMap"
:node-key="objMap.value"
:expand-on-click-node="false"
:default-expanded-keys="defaultExpandedKey"
:filter-node-method="filterNode"
@node-click="handleNodeClick"
></el-tree>
</el-option>
</el-select>
</div>
</template>
<script setup>
const { proxy } = getCurrentInstance();
const props = defineProps({
/* 配置项 */
objMap: {
type: Object,
default: () => {
return {
value: 'id', // ID字段名
label: 'label', // 显示名称
children: 'children' // 子级字段名
}
}
},
/* 自动收起 */
accordion: {
type: Boolean,
default: () => {
return false
}
},
/**当前双向数据绑定的值 */
value: {
type: [String, Number],
default: ''
},
/**当前的数据 */
options: {
type: Array,
default: () => []
},
/**输入框内部的文字 */
placeholder: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:value']);
const valueId = computed({
get: () => props.value,
set: (val) => {
emit('update:value', val)
}
});
const valueTitle = ref('');
const defaultExpandedKey = ref([]);
function initHandle() {
nextTick(() => {
const selectedValue = valueId.value;
if(selectedValue !== null && typeof (selectedValue) !== 'undefined') {
const node = proxy.$refs.selectTree.getNode(selectedValue)
if (node) {
valueTitle.value = node.data[props.objMap.label]
proxy.$refs.selectTree.setCurrentKey(selectedValue) // 设置默认选中
defaultExpandedKey.value = [selectedValue] // 设置默认展开
}
} else {
clearHandle()
}
})
}
function handleNodeClick(node) {
valueTitle.value = node[props.objMap.label]
valueId.value = node[props.objMap.value];
defaultExpandedKey.value = [];
proxy.$refs.treeSelect.blur()
selectFilterData('')
}
function selectFilterData(val) {
proxy.$refs.selectTree.filter(val)
}
function filterNode(value, data) {
if (!value) return true
return data[props.objMap['label']].indexOf(value) !== -1
}
function clearHandle() {
valueTitle.value = ''
valueId.value = ''
defaultExpandedKey.value = [];
clearSelected()
}
function clearSelected() {
const allNode = document.querySelectorAll('#tree-option .el-tree-node')
allNode.forEach((element) => element.classList.remove('is-current'))
}
onMounted(() => {
initHandle()
})
watch(valueId, () => {
initHandle();
})
</script>
<style lang='scss' scoped>
@import "@/assets/styles/variables.module.scss";
.el-scrollbar .el-scrollbar__view .el-select-dropdown__item {
padding: 0;
background-color: #fff;
height: auto;
}
.el-select-dropdown__item.selected {
font-weight: normal;
}
ul li .el-tree .el-tree-node__content {
height: auto;
padding: 0 20px;
box-sizing: border-box;
}
:deep(.el-tree-node__content:hover),
:deep(.el-tree-node__content:active),
:deep(.is-current > div:first-child),
:deep(.el-tree-node__content:focus) {
background-color: mix(#fff, $--color-primary, 90%);
color: $--color-primary;
}
</style>

View File

@ -0,0 +1,31 @@
<template>
<div v-loading="loading" :style="'height:' + height">
<iframe
:src="url"
frameborder="no"
style="width: 100%; height: 100%"
scrolling="auto" />
</div>
</template>
<script setup>
const props = defineProps({
src: {
type: String,
required: true
}
})
const height = ref(document.documentElement.clientHeight - 94.5 + "px;")
const loading = ref(true)
const url = computed(() => props.src)
onMounted(() => {
setTimeout(() => {
loading.value = false;
}, 300);
window.onresize = function temp() {
height.value = document.documentElement.clientHeight - 94.5 + "px;";
};
})
</script>