first commit

This commit is contained in:
2025-08-19 10:19:29 +08:00
commit 3b61e84a7f
3014 changed files with 2640574 additions and 0 deletions

View File

@ -0,0 +1,95 @@
<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 lang="ts">
import { RouteLocationMatched } from 'vue-router';
import usePermissionStore from '@/store/modules/permission';
const route = useRoute();
const router = useRouter();
const permissionStore = usePermissionStore();
const levelList = ref<RouteLocationMatched[]>([]);
const getBreadcrumb = () => {
// only show routes with meta.title
let matched = [];
const pathNum = findPathNum(route.path);
// multi-level menu
if (pathNum > 2) {
const reg = /\/\w+/gi;
const pathList = route.path.match(reg).map((item, index) => {
if (index !== 0) item = item.slice(1);
return item;
});
getMatched(pathList, permissionStore.defaultRoutes, matched);
} else {
matched = route.matched.filter((item) => item.meta && item.meta.title);
}
// 判断是否为首页
if (!isDashboard(matched[0])) {
matched = [{ path: '/index', meta: { title: '首页' } }].concat(matched);
}
levelList.value = matched.filter((item) => item.meta && item.meta.title && item.meta.breadcrumb !== false);
};
const findPathNum = (str, char = '/') => {
let index = str.indexOf(char);
let num = 0;
while (index !== -1) {
num++;
index = str.indexOf(char, index + 1);
}
return num;
};
const getMatched = (pathList, routeList, matched) => {
let data = routeList.find((item) => item.path == pathList[0] || (item.name += '').toLowerCase() == pathList[0]);
if (data) {
matched.push(data);
if (data.children && pathList.length) {
pathList.shift();
getMatched(pathList, data.children, matched);
}
}
};
const isDashboard = (route: RouteLocationMatched) => {
const name = route && (route.name as string);
if (!name) {
return false;
}
return name.trim() === 'Index';
};
const handleLink = (item) => {
const { redirect, path } = item;
redirect ? router.push(redirect) : router.push(path);
};
watchEffect(() => {
// if you go to the redirect page, do not update the breadcrumbs
if (route.path.startsWith('/redirect/')) return;
getBreadcrumb();
});
onMounted(() => {
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,61 @@
<template>
<!-- 代码构建 -->
<div>
<v-form-designer
ref="buildRef"
class="build"
:designer-config="{ importJsonButton: true, exportJsonButton: true, exportCodeButton: true, generateSFCButton: true, formTemplates: true }"
>
<template v-if="showBtn" #customToolButtons>
<el-button link type="primary" icon="Select" @click="getJson">保存</el-button>
</template>
</v-form-designer>
</div>
</template>
<script setup lang="ts">
interface Props {
showBtn: boolean;
formJson: any;
}
const props = withDefaults(defineProps<Props>(), {
showBtn: true,
formJson: ''
});
const buildRef = ref();
const emits = defineEmits(['reJson', 'saveDesign']);
//获取表单json
const getJson = () => {
const formJson = JSON.stringify(buildRef.value.getFormJson());
const fieldJson = JSON.stringify(buildRef.value.getFieldWidgets());
let data = {
formJson,
fieldJson
};
emits('saveDesign', data);
};
onMounted(() => {
if (props.formJson) {
buildRef.value.setFormJson(props.formJson);
}
});
</script>
<style lang="scss">
.build {
margin: 0 !important;
overflow-y: auto !important;
& header.main-header {
display: none;
}
& .right-toolbar-con {
text-align: right !important;
}
}
</style>

View File

@ -0,0 +1,57 @@
<template>
<div class="">
<v-form-render ref="vFormRef" :form-json="formJson" :form-data="formData" />
</div>
</template>
<!-- 动态表单渲染 -->
<script setup name="Render" lang="ts">
interface Props {
formJson: string | object;
formData: string | object;
isView: boolean;
}
const props = withDefaults(defineProps<Props>(), {
formJson: '',
formData: '',
isView: false
});
const vFormRef = ref();
// 获取表单数据-异步
const getFormData = () => {
return vFormRef.value.getFormData();
};
/**
* 设置表单内容
* @param {表单配置} formConf
* formConfig{ formTemplate表单模板formData表单数据hiddenField需要隐藏的字段字符串集合disabledField需要禁用的自读字符串集合}
*/
const initForm = (formConf: any) => {
const { formTemplate, formData, hiddenField, disabledField } = toRaw(formConf);
if (formTemplate) {
vFormRef.value.setFormJson(formTemplate);
if (formData) {
vFormRef.value.setFormData(formData);
}
if (disabledField && disabledField.length > 0) {
setTimeout(() => {
vFormRef.value.disableWidgets(disabledField);
}, 200);
}
if (hiddenField && hiddenField.length > 0) {
setTimeout(() => {
vFormRef.value.hideWidgets(hiddenField);
}, 200);
}
if (props.isView) {
setTimeout(() => {
vFormRef.value.disableForm();
}, 100);
}
}
};
defineExpose({ getFormData, initForm });
</script>

View File

@ -0,0 +1,94 @@
<template>
<div>
<template v-for="(item, index) in options">
<template v-if="values.includes(item.value)">
<span
v-if="(item.elTagType === 'default' || item.elTagType === '') && (item.elTagClass === '' || item.elTagClass == null)"
:key="item.value"
:index="index"
:class="item.elTagClass"
>
{{ item.label + ' ' }}
</span>
<el-tag
v-else
:key="item.value + ''"
:disable-transitions="true"
:index="index"
:type="
item.elTagType === 'primary' ||
item.elTagType === 'success' ||
item.elTagType === 'info' ||
item.elTagType === 'warning' ||
item.elTagType === 'danger'
? item.elTagType
: 'primary'
"
:class="item.elTagClass"
>
{{ item.label + ' ' }}
</el-tag>
</template>
</template>
<template v-if="unmatch && showValue">
{{ unmatchArray }}
</template>
</div>
</template>
<script setup lang="ts">
interface Props {
options: Array<DictDataOption>;
value: number | string | Array<number | string>;
showValue?: boolean;
separator?: string;
}
const props = withDefaults(defineProps<Props>(), {
showValue: true,
separator: ','
});
const values = computed(() => {
if (props.value === '' || props.value === null || typeof props.value === 'undefined') return [];
return Array.isArray(props.value) ? props.value.map((item) => '' + item) : String(props.value).split(props.separator);
});
const unmatch = computed(() => {
if (props.options?.length == 0 || props.value === '' || props.value === null || typeof props.value === 'undefined') return false;
// 传入值为非数组
let unmatch = false; // 添加一个标志来判断是否有未匹配项
values.value.forEach((item) => {
if (!props.options.some((v) => v.value === item)) {
unmatch = true; // 如果有未匹配项将标志设置为true
}
});
return unmatch; // 返回标志的值
});
const unmatchArray = computed(() => {
// 记录未匹配的项
const itemUnmatchArray: Array<string | number> = [];
if (props.value !== '' && props.value !== null && typeof props.value !== 'undefined') {
values.value.forEach((item) => {
if (!props.options.some((v) => v.value === item)) {
itemUnmatchArray.push(item);
}
});
}
// 没有value不显示
return handleArray(itemUnmatchArray);
});
const handleArray = (array: Array<string | number>) => {
if (array.length === 0) return '';
return array.reduce((pre, cur) => {
return pre + ' ' + cur;
});
};
</script>
<style scoped>
.el-tag + .el-tag {
margin-left: 10px;
}
</style>

View File

@ -0,0 +1,244 @@
<template>
<div>
<el-upload
v-if="type === 'url'"
:action="upload.url"
:before-upload="handleBeforeUpload"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
class="editor-img-uploader"
name="file"
:show-file-list="false"
:headers="upload.headers"
>
<i ref="uploadRef"></i>
</el-upload>
</div>
<div class="editor">
<quill-editor
ref="quillEditorRef"
v-model:content="content"
content-type="html"
:options="options"
:style="styles"
@text-change="(e: any) => $emit('update:modelValue', content)"
/>
</div>
</template>
<script setup lang="ts">
import '@vueup/vue-quill/dist/vue-quill.snow.css';
import { QuillEditor, Quill } from '@vueup/vue-quill';
import { propTypes } from '@/utils/propTypes';
import { globalHeaders } from '@/utils/request';
defineEmits(['update:modelValue']);
const props = defineProps({
/* 编辑器的内容 */
modelValue: propTypes.string,
/* 高度 */
height: propTypes.number.def(400),
/* 最小高度 */
minHeight: propTypes.number.def(400),
/* 只读 */
readOnly: propTypes.bool.def(false),
/* 上传文件大小限制(MB) */
fileSize: propTypes.number.def(5),
/* 类型base64格式、url格式 */
type: propTypes.string.def('url')
});
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const upload = reactive<UploadOption>({
headers: globalHeaders(),
url: import.meta.env.VITE_APP_BASE_API + '/resource/oss/upload'
});
const quillEditorRef = ref();
const uploadRef = ref<HTMLDivElement>();
const options = ref<any>({
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: (value: boolean) => {
if (value) {
// 调用element图片上传
uploadRef.value.click();
} else {
Quill.format('image', true);
}
}
}
}
},
placeholder: '请输入内容',
readOnly: props.readOnly
});
const styles = computed(() => {
let style: any = {};
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: string) => {
if (v !== content.value) {
content.value = v || '<p></p>';
}
},
{ immediate: true }
);
// 图片上传成功返回图片地址
const handleUploadSuccess = (res: any) => {
// 如果上传成功
if (res.code === 200) {
// 获取富文本实例
let quill = toRaw(quillEditorRef.value).getQuill();
// 获取光标位置
let length = quill.selection.savedRange.index;
// 插入图片res为服务器返回的图片链接地址
quill.insertEmbed(length, 'image', res.data.url);
// 调整光标到最后
quill.setSelection(length + 1);
proxy?.$modal.closeLoading();
} else {
proxy?.$modal.msgError('图片插入失败');
proxy?.$modal.closeLoading();
}
};
// 图片上传前拦截
const handleBeforeUpload = (file: any) => {
const type = ['image/jpeg', 'image/jpg', 'image/png', 'image/svg'];
const isJPG = type.includes(file.type);
//检验文件格式
if (!isJPG) {
proxy?.$modal.msgError(`图片格式错误!`);
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('正在上传文件,请稍候...');
return true;
};
// 图片失败拦截
const handleUploadError = (err: any) => {
proxy?.$modal.msgError('上传文件失败');
};
</script>
<style>
.editor-img-uploader {
display: none;
}
.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: 0;
content: '保存';
padding-right: 0;
}
.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,490 @@
<template>
<div class="upload-file">
<el-upload
ref="fileUploadRef"
multiple
:action="realUploadUrl"
:before-upload="handleBeforeUpload"
:file-list="fileList"
:limit="limit"
:on-error="handleUploadError"
:on-exceed="handleExceed"
:on-success="handleUploadSuccess"
:show-file-list="showFileList"
:headers="headers"
class="upload-file-uploader"
:list-type="isConstruction ? 'picture-card' : 'text'"
:accept="accept"
:drag="isDarg"
:data="data"
:auto-upload="autoUpload"
:on-change="handleChange"
:on-remove="handleRemove"
:method="method"
:http-request="customUpload"
>
<slot>
<div>
<!-- 上传按钮 -->
<el-button v-if="!isConstruction && !isImportInfo && !isDarg" type="primary">选取文件</el-button>
<!-- 上传提示 -->
<el-icon v-if="isDarg" class="el-icon--upload"><upload-filled /></el-icon>
<div v-if="showTip" class="el-upload__tip" @click.stop>
请上传
<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
v-if="!isConstruction && !isImportInfo"
class="upload-file-list el-upload-list el-upload-list--text"
name="el-fade-in-linear"
tag="ul"
@click.stop
>
<li
style="margin-top: 10px;"
v-for="(file, index) in fileList"
:key="file.uid"
class="el-upload-list__item ele-upload-list__item-content"
>
<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-button type="danger" link @click="handleDelete(index)">删除</el-button>
</div>
</li>
</transition-group>
</div>
</slot>
<el-icon v-if="isConstruction"><Plus /></el-icon>
<template #file="{ file }">
<div class="pdf" v-if="isConstruction">
<img src="@/assets/icons/svg/pdf.png" alt="" />
<el-text class="w-148px text-center" truncated>
<span>{{ file.name }}</span>
</el-text>
<div class="Shadow">
<a :href="file.url" target="_blank">
<el-icon class="mr"><View /></el-icon>
</a>
<a href="#">
<el-icon @click="handleDelete((file as any).ossId, 'ossId')"><Delete /></el-icon>
</a>
</div>
</div>
</template>
</el-upload>
</div>
</template>
<script setup lang="ts">
import { propTypes } from '@/utils/propTypes';
import { delOss, listByIds } from '@/api/system/oss';
import { globalHeaders } from '@/utils/request';
import axios from 'axios';
const props = defineProps({
modelValue: {
type: [String, Object, Array],
default: () => []
},
// 数量限制
limit: propTypes.number.def(5),
// 大小限制(MB)
fileSize: propTypes.number.def(5),
// 文件类型, 例如['png', 'jpg', 'jpeg']
fileType: propTypes.array.def(['doc', 'xls', 'ppt', 'txt', 'pdf', 'png', 'jpg', 'jpeg', 'zip']),
// 是否显示提示
isShowTip: propTypes.bool.def(true),
//是否为施工人员上传
isConstruction: propTypes.bool.def(false),
//是否为上传zip文件
isImportInfo: propTypes.bool.def(false),
//ip地址
uploadUrl: propTypes.string.def('/resource/oss/upload'),
//可拖拽上传
isDarg: propTypes.bool.def(false),
// 是否自动上传
autoUpload: propTypes.bool.def(true),
// 是否显示文件列表
showFileList: propTypes.bool.def(false),
// 其他参数
data: propTypes.object.def({}),
// 成功回调
onUploadSuccess: {
type: Function as PropType<(files: any[], res: any) => void>,
default: undefined
},
// 上传方法
method: propTypes.string.def('post'),
// 失败回调
onUploadError: {
type: Function as PropType<(err: any, file: any, fileList: any) => void>,
default: undefined
},
params: {
type: Object,
default: () => ({})
}
});
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const emit = defineEmits(['update:modelValue', 'handleChange', 'handleRemove']);
const number = ref(0);
const uploadList = ref<any[]>([]);
const baseUrl = import.meta.env.VITE_APP_BASE_API;
const uploadFileUrl = ref(baseUrl + props.uploadUrl); // 上传文件服务器地址
const headers = ref(globalHeaders());
const pendingFiles = ref<UploadFile[]>([]);
const realUploadUrl = computed(() => {
const search = new URLSearchParams(props.params).toString();
return search ? `${baseUrl}${props.uploadUrl}?${search}` : `${baseUrl}${props.uploadUrl}`;
});
const fileList = ref<any[]>([]);
const showTip = computed(() => props.isShowTip && (props.fileType || props.fileSize));
const fileUploadRef = ref<ElUploadInstance>();
const accept = computed(() => {
return props.fileType.map((value) => `.${value}`).join(',');
});
watch(
() => props.modelValue,
async (val) => {
if (props.isImportInfo) return;
if (val) {
let temp = 1;
// 首先将值转为数组
let list: any[] = [];
if (Array.isArray(val)) {
list = val;
} else {
const res = await listByIds(val as any);
list = res.data.map((oss) => {
return {
name: oss.originalName,
url: oss.url,
ossId: oss.ossId
};
});
}
// 然后将数组转为对象数组
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 }
);
// 上传前校检格式和大小
const handleBeforeUpload = (file: any) => {
// 校检文件类型
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 (file.name.includes(',')) {
proxy?.$modal.msgError('文件名不正确,不能包含英文逗号!');
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;
};
// 文件个数超出
const handleExceed = () => {
proxy?.$modal.msgError(`上传文件数量不能超过 ${props.limit} 个!`);
};
// 上传失败
const handleUploadError = () => {
proxy?.$modal.msgError('上传文件失败');
};
// 上传成功回调
interface UploadFileWithOssId extends UploadFile {
ossId?: string;
}
const handleUploadSuccess = (res: any, file: UploadFileWithOssId) => {
if (res.code === 200) {
console.log('上传成功');
// 上传成功,不管 data 是否为空
uploadList.value.push({
name: file.name,
url: (res.data && res.data.url) || '',
ossId: (res.data && res.data.ossId) || ''
});
} else {
console.log('失败', res);
number.value--;
proxy?.$modal.closeLoading();
proxy?.$modal.msgError(res.msg || '上传失败');
fileUploadRef.value?.handleRemove(file);
return;
}
uploadedSuccessfully(res);
};
const handleChange = (file: any, fileList: any) => {
// 记录 status = 'ready' 的文件
if (file.status === 'ready') {
pendingFiles.value.push(file);
}
emit('handleChange', file, fileList);
};
// 删除文件
const handleRemove = (file: any, fileList: any) => {
console.log(11);
emit('handleRemove', file, fileList);
};
// 删除文件
const handleDelete = async (index: string | number, type?: string) => {
await proxy?.$modal.confirm('是否确认删除此文件?').finally();
try {
if (type === 'ossId') {
delOss(index);
fileList.value = fileList.value.filter((f) => f.ossId !== index);
} else {
let ossId = fileList.value[index].ossId;
delOss(ossId);
index = parseInt(index as string);
fileList.value.splice(index, 1);
}
} finally {
emit('handleRemove');
emit('update:modelValue', listToString(fileList.value));
}
};
// 上传结束处理
const uploadedSuccessfully = (res: any) => {
if (props.isImportInfo) {
emit('update:modelValue', 'ok');
fileUploadRef.value?.clearFiles();
proxy?.$modal.closeLoading();
proxy?.$modal.msgSuccess('导入成功');
return;
}
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();
}
if (props.autoUpload && props.limit === fileList.value.length) {
fileUploadRef.value?.clearFiles();
fileList.value = [];
emit('update:modelValue', ''); // 同步到外部 v-model
}
props.onUploadSuccess?.(fileList.value, res);
};
// 获取文件名称
const getFileName = (name: string) => {
// 如果是url那么取最后的名字 如果不是直接返回
if (!name) return '';
if (name.lastIndexOf('/') > -1) {
return name.slice(name.lastIndexOf('/') + 1);
} else {
return name;
}
};
// 对象转成指定字符串分隔
const listToString = (list: any[], separator?: string) => {
let strs = '';
separator = separator || ',';
list.forEach((item) => {
if (item.ossId) {
strs += item.ossId + separator;
}
});
return strs != '' ? strs.substring(0, strs.length - 1) : '';
};
// 改造后的 customUpload
const customUpload = async (options: any) => {
if (props.autoUpload) {
// 自动上传,单文件请求
try {
const formData = new FormData();
formData.append('file', options.file);
Object.entries(props.data).forEach(([k, v]) => {
if (v !== null && v !== undefined) formData.append(k, v as any);
});
const res = await axios?.({
url: realUploadUrl.value,
method: props.method,
data: formData,
headers: { 'Content-Type': 'multipart/form-data', ...headers.value }
});
handleUploadSuccess(res.data, options.file);
} catch (err) {
handleUploadError();
}
} else {
// 手动上传,不发请求,只缓存
pendingFiles.value.push(options.file);
}
};
// 改造后的 submitUpload
const submitUpload = async () => {
if (props.autoUpload) {
fileUploadRef.value?.submit();
return;
}
if (!pendingFiles.value.length) {
return 'noFile';
}
try {
proxy?.$modal.loading('正在上传文件,请稍候...');
const formData = new FormData();
pendingFiles.value.forEach((f) => {
if (f.raw) formData.append('file', f.raw as File);
});
Object.entries(props.data).forEach(([k, v]) => {
if (v !== null && v !== undefined) formData.append(k, v as any);
});
const res = await axios?.({
url: realUploadUrl.value,
method: props.method,
data: formData,
headers: { 'Content-Type': 'multipart/form-data', ...headers.value }
});
handleUploadSuccess(res.data, {} as any);
pendingFiles.value = [];
fileUploadRef.value?.clearFiles();
} catch (err) {
handleUploadError();
} finally {
proxy?.$modal.closeLoading();
}
};
defineExpose({ submitUpload });
</script>
<style scoped lang="scss">
.pdf {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
border-radius: 6px;
position: relative;
width: 100%;
img {
width: 40%;
}
&:hover {
.Shadow {
opacity: 1;
}
}
> span {
width: 100%;
}
}
.upload-file-list {
margin: 0;
.el-upload-list__item {
border: 1px solid #e4e7ed;
line-height: 2;
margin-bottom: 0;
position: relative;
}
}
.upload-file-list .ele-upload-list__item-content {
display: flex;
justify-content: space-between;
align-items: center;
color: inherit;
}
.Shadow {
align-items: center;
background-color: rgba(0, 0, 0, 0.5);
color: #fff;
cursor: default;
display: inline-flex;
font-size: 20px;
height: 100%;
justify-content: center;
left: 0;
opacity: 0;
position: absolute;
top: 0;
transition: opacity 0.3s;
width: 100%;
z-index: 1;
}
.ele-upload-list__item-content-action .el-link {
margin-right: 10px;
}
.el-icon.avatar-uploader-icon {
border: 1px dashed #cdd0d6;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: 0.3s;
}
.el-icon.avatar-uploader-icon:hover {
border-color: #409eff;
}
.el-icon.avatar-uploader-icon {
font-size: 28px;
color: #8c939d;
width: 200px;
height: 178px;
text-align: center;
}
</style>

View File

@ -0,0 +1,35 @@
<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 lang="ts">
import { propTypes } from '@/utils/propTypes';
defineProps({
isActive: propTypes.bool.def(false)
});
const emit = defineEmits(['toggleClick']);
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,195 @@
<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 lang="ts" name="HeaderSearch">
import Fuse from 'fuse.js';
import { getNormalPath } from '@/utils/ruoyi';
import { isHttp } from '@/utils/validate';
import usePermissionStore from '@/store/modules/permission';
import { RouteRecordRaw } from 'vue-router';
type Router = Array<{
path: string;
title: string[];
}>;
const search = ref('');
const options = ref<any>([]);
const searchPool = ref<Router>([]);
const show = ref(false);
const fuse = ref();
const headerSearchSelectRef = ref<ElSelectInstance>();
const router = useRouter();
const routes = computed(() => usePermissionStore().getRoutes());
const click = () => {
show.value = !show.value;
if (show.value) {
headerSearchSelectRef.value && headerSearchSelectRef.value.focus();
}
};
const close = () => {
headerSearchSelectRef.value && headerSearchSelectRef.value.blur();
options.value = [];
show.value = false;
};
const change = (val: any) => {
const path = val.path;
const query = val.query;
if (isHttp(path)) {
// http(s):// 路径新窗口打开
const pindex = path.indexOf('http');
window.open(path.substr(pindex, path.length), '_blank');
} else {
if (query) {
router.push({ path: path, query: JSON.parse(query) });
} else {
router.push(path);
}
}
search.value = '';
options.value = [];
nextTick(() => {
show.value = false;
});
};
const initFuse = (list: Router) => {
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
const generateRoutes = (routes: RouteRecordRaw[], basePath = '', prefixTitle: string[] = []) => {
let res: Router = [];
routes.forEach((r) => {
// skip hidden router
if (!r.hidden) {
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],
query: ''
};
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);
}
}
if (r.query) {
data.query = r.query;
}
// recursive child routes
if (r.children) {
const tempRoutes = generateRoutes(r.children, data.path, data.title);
if (tempRoutes.length >= 1) {
res = [...res, ...tempRoutes];
}
}
}
});
return res;
};
const querySearch = (query: string) => {
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: Router) => {
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,104 @@
<template>
<div class="relative" :style="{ 'width': width }">
<el-input v-model="modelValue" readonly placeholder="点击选择图标" @click="visible = !visible">
<template #prepend>
<svg-icon :icon-class="modelValue" />
</template>
</el-input>
<el-popover shadow="none" :visible="visible" placement="bottom-end" trigger="click" :width="450">
<template #reference>
<div class="cursor-pointer text-[#999] absolute right-[10px] top-0 height-[32px] leading-[32px]" @click="visible = !visible">
<i-ep-caret-top v-show="visible"></i-ep-caret-top>
<i-ep-caret-bottom v-show="!visible"></i-ep-caret-bottom>
</div>
</template>
<el-input v-model="filterValue" class="p-2" placeholder="搜索图标" clearable @input="filterIcons" />
<el-scrollbar height="w-[200px]">
<ul class="icon-list">
<el-tooltip v-for="(iconName, index) in iconNames" :key="index" :content="iconName" placement="bottom" effect="light">
<li :class="['icon-item', { active: modelValue == iconName }]" @click="selectedIcon(iconName)">
<svg-icon color="var(--el-text-color-regular)" :icon-class="iconName" />
</li>
</el-tooltip>
</ul>
</el-scrollbar>
</el-popover>
</div>
</template>
<script setup lang="ts">
import icons from '@/components/IconSelect/requireIcons';
import { propTypes } from '@/utils/propTypes';
const props = defineProps({
modelValue: propTypes.string.isRequired,
width: propTypes.string.def('400px')
});
const emit = defineEmits(['update:modelValue']);
const visible = ref(false);
const { modelValue, width } = toRefs(props);
const iconNames = ref<string[]>(icons);
const filterValue = ref('');
/**
* 筛选图标
*/
const filterIcons = () => {
if (filterValue.value) {
iconNames.value = icons.filter((iconName) => iconName.includes(filterValue.value));
} else {
iconNames.value = icons;
}
};
/**
* 选择图标
* @param iconName 选择的图标名称
*/
const selectedIcon = (iconName: string) => {
emit('update:modelValue', iconName);
visible.value = false;
};
</script>
<style scoped lang="scss">
.el-scrollbar {
max-height: calc(50vh - 100px) !important;
overflow-y: auto;
}
.el-divider--horizontal {
margin: 10px auto !important;
}
.icon-list {
display: flex;
flex-wrap: wrap;
padding-left: 10px;
margin-top: 10px;
.icon-item {
cursor: pointer;
width: 10%;
margin: 0 10px 10px 0;
padding: 5px;
display: flex;
flex-direction: column;
justify-items: center;
align-items: center;
border: 1px solid #ccc;
&:hover {
border-color: var(--el-color-primary);
color: var(--el-color-primary);
transition: all 0.2s;
transform: scaleX(1.1);
}
}
.active {
border-color: var(--el-color-primary);
color: var(--el-color-primary);
}
}
</style>

View File

@ -0,0 +1,7 @@
const icons: string[] = [];
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,80 @@
<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 lang="ts">
import { propTypes } from '@/utils/propTypes';
import { PictureFilled } from '@element-plus/icons-vue';
const props = defineProps({
src: propTypes.string.def(''),
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: string[] = [];
real_src_list.forEach((item: string) => {
if (item.trim() === '') {
return;
}
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,239 @@
<template>
<div class="component-upload-image">
<el-upload
ref="imageUpload"
multiple
:action="uploadImgUrl"
list-type="picture-card"
:on-success="handleUploadSuccess"
:before-upload="handleBeforeUpload"
:limit="limit"
:on-error="handleUploadError"
:on-exceed="handleExceed"
:before-remove="handleDelete"
:show-file-list="true"
:headers="headers"
:file-list="fileList"
:on-preview="handlePictureCardPreview"
:class="{ hide: fileList.length >= limit }"
accept="image/png, image/jpeg, image/jpg"
>
<el-icon class="avatar-uploader-icon">
<plus />
</el-icon>
</el-upload>
<!-- 上传提示 -->
<div v-if="showTip" class="el-upload__tip">
请上传
<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 lang="ts">
import { listByIds, delOss } from '@/api/system/oss';
import { OssVO } from '@/api/system/oss/types';
import { propTypes } from '@/utils/propTypes';
import { globalHeaders } from '@/utils/request';
import { compressAccurately } from 'image-conversion';
const props = defineProps({
modelValue: {
type: [String, Object, Array],
default: () => []
},
// 图片数量限制
limit: propTypes.number.def(5),
// 大小限制(MB)
fileSize: propTypes.number.def(5),
// 文件类型, 例如['png', 'jpg', 'jpeg']
fileType: propTypes.array.def(['png', 'jpg', 'jpeg']),
// 是否显示提示
isShowTip: {
type: Boolean,
default: true
},
// 是否支持压缩,默认否
compressSupport: {
type: Boolean,
default: false
},
// 压缩目标大小单位KB。默认300KB以上文件才压缩并压缩至300KB以内
compressTargetSize: propTypes.number.def(300)
});
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const emit = defineEmits(['update:modelValue']);
const number = ref(0);
const uploadList = ref<any[]>([]);
const dialogImageUrl = ref('');
const dialogVisible = ref(false);
const baseUrl = import.meta.env.VITE_APP_BASE_API;
const uploadImgUrl = ref(baseUrl + '/resource/oss/upload'); // 上传的图片服务器地址
const headers = ref(globalHeaders());
const fileList = ref<any[]>([]);
const showTip = computed(() => props.isShowTip && (props.fileType || props.fileSize));
const imageUploadRef = ref<ElUploadInstance>();
watch(
() => props.modelValue,
async (val: string) => {
if (val) {
// 首先将值转为数组
let list: OssVO[] = [];
if (Array.isArray(val)) {
list = val as OssVO[];
} else {
const res = await listByIds(val);
list = res.data;
}
// 然后将数组转为对象数组
fileList.value = list.map((item) => {
// 字符串回显处理 如果此处存的是url可直接回显 如果存的是id需要调用接口查出来
let itemData;
if (typeof item === 'string') {
itemData = { name: item, url: item };
} else {
// 此处name使用ossId 防止删除出现重名
itemData = { name: item.ossId, url: item.url, ossId: item.ossId };
}
return itemData;
});
} else {
fileList.value = [];
return [];
}
},
{ deep: true, immediate: true }
);
/** 上传前loading加载 */
const handleBeforeUpload = (file: any) => {
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: any) => {
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 (file.name.includes(',')) {
proxy?.$modal.msgError('文件名不正确,不能包含英文逗号!');
return false;
}
if (props.fileSize) {
const isLt = file.size / 1024 / 1024 < props.fileSize;
if (!isLt) {
proxy?.$modal.msgError(`上传头像图片大小不能超过 ${props.fileSize} MB!`);
return false;
}
}
//压缩图片,开启压缩并且大于指定的压缩大小时才压缩
if (props.compressSupport && file.size / 1024 > props.compressTargetSize) {
proxy?.$modal.loading('正在上传图片,请稍候...');
number.value++;
return compressAccurately(file, props.compressTargetSize);
} else {
proxy?.$modal.loading('正在上传图片,请稍候...');
number.value++;
}
};
// 文件个数超出
const handleExceed = () => {
proxy?.$modal.msgError(`上传文件数量不能超过 ${props.limit} 个!`);
};
// 上传成功回调
const handleUploadSuccess = (res: any, file: UploadFile) => {
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);
imageUploadRef.value?.handleRemove(file);
uploadedSuccessfully();
}
};
// 删除图片
const handleDelete = (file: UploadFile): boolean => {
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;
}
return true;
};
// 上传结束处理
const 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();
}
};
// 上传失败
const handleUploadError = () => {
proxy?.$modal.msgError('上传图片失败');
proxy?.$modal.closeLoading();
};
// 预览
const handlePictureCardPreview = (file: any) => {
dialogImageUrl.value = file.url;
dialogVisible.value = true;
};
// 对象转成指定字符串分隔
const listToString = (list: any[], separator?: string) => {
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.substring(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,39 @@
<template>
<el-dropdown trigger="click" @command="handleLanguageChange">
<div class="lang-select--style">
<svg-icon icon-class="language" />
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item :disabled="appStore.language === 'zh_CN'" command="zh_CN"> 中文 </el-dropdown-item>
<el-dropdown-item :disabled="appStore.language === 'en_US'" command="en_US"> English </el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { useAppStore } from '@/store/modules/app';
import SvgIcon from '@/components/SvgIcon/index.vue';
const appStore = useAppStore();
const { locale } = useI18n();
const message: any = {
zh_CN: '切换语言成功!',
en_US: 'Switch Language Successful!'
};
const handleLanguageChange = (lang: any) => {
locale.value = lang;
appStore.changeLanguage(lang);
ElMessage.success(message[lang] || '切换语言成功!');
};
</script>
<style lang="scss" scoped>
.lang-select--style {
font-size: 18px;
line-height: 50px;
}
</style>

View File

@ -0,0 +1,94 @@
<template>
<div :class="{ hidden: hidden }" class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:size="size"
:background="background"
:layout="layout"
:page-sizes="pageSizes"
:pager-count="pagerCount"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</template>
<script lang="ts">
export default {
name: 'Pagination'
};
</script>
<script setup lang="ts">
import { scrollTo } from '@/utils/scroll-to';
import { propTypes } from '@/utils/propTypes';
const props = defineProps({
total: propTypes.number,
page: propTypes.number.def(1),
limit: propTypes.number.def(20),
pageSizes: {
type: Array<number>,
default: () => [10, 20, 30, 50]
},
// 移动端页码按钮的数量端默认值5
pagerCount: propTypes.number.def(document.body.clientWidth < 992 ? 5 : 7),
layout: propTypes.string.def('total, sizes, prev, pager, next, jumper'),
background: propTypes.bool.def(true),
autoScroll: propTypes.bool.def(true),
hidden: propTypes.bool.def(false),
float: propTypes.string.def('right'),
size: propTypes.any
});
const emit = defineEmits(['update:page', 'update:limit', 'pagination']);
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: number) {
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: number) {
emit('pagination', { page: val, limit: pageSize.value });
if (props.autoScroll) {
scrollTo(0, 800);
}
}
</script>
<style lang="scss" scoped>
.pagination-container {
padding: 32px 16px;
.el-pagination {
float: v-bind(float);
}
}
.pagination-container.hidden {
display: none;
}
</style>

View File

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

View File

@ -0,0 +1,59 @@
<template>
<div style="display: flex; justify-content: space-between">
<div>
<el-button v-if="submitButtonShow" :loading="props.buttonLoading" type="info" @click="submitForm('draft')">暂存</el-button>
<el-button v-if="submitButtonShow" :loading="props.buttonLoading" type="primary" @click="submitForm('submit')"> </el-button>
<el-button v-if="approvalButtonShow" :loading="props.buttonLoading" type="primary" @click="approvalVerifyOpen">审批</el-button>
<el-button v-if="props.id && props.status !== 'draft'" type="primary" @click="handleApprovalRecord">流程进度</el-button>
<slot />
</div>
<div>
<el-button style="float: right" @click="goBack()">返回</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { propTypes } from '@/utils/propTypes';
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const route = useRoute();
const router = useRouter();
const props = defineProps({
status: propTypes.string.def(''),
pageType: propTypes.string.def(''),
buttonLoading: propTypes.bool.def(false),
id: propTypes.string.def('') || propTypes.number.def()
});
const emits = defineEmits(['submitForm', 'approvalVerifyOpen', 'handleApprovalRecord']);
//暂存,提交
const submitForm = async (type) => {
emits('submitForm', type);
};
//审批
const approvalVerifyOpen = async () => {
emits('approvalVerifyOpen');
};
//审批记录
const handleApprovalRecord = () => {
emits('handleApprovalRecord');
};
//校验提交按钮是否显示
const submitButtonShow = computed(() => {
return (
props.pageType === 'add' ||
(props.pageType === 'update' && props.status && (props.status === 'draft' || props.status === 'cancel' || props.status === 'back'))
);
});
//校验审批按钮是否显示
const approvalButtonShow = computed(() => {
return props.pageType === 'approval' && props.status && props.status === 'waiting';
});
//返回
const goBack = () => {
proxy.$tab.closePage(route);
router.go(-1);
};
</script>

View File

@ -0,0 +1,131 @@
<template>
<div class="container">
<el-dialog v-model="visible" draggable title="审批记录" :width="props.width" :height="props.height" :close-on-click-modal="false">
<el-tabs v-model="tabActiveName" class="demo-tabs">
<el-tab-pane v-loading="loading" label="流程图" name="image" style="height: 68vh">
<flowChart :ins-id="insId" v-if="insId" />
</el-tab-pane>
<el-tab-pane v-loading="loading" label="审批信息" name="info">
<div>
<el-table :data="historyList" style="width: 100%" border fit>
<el-table-column type="index" label="序号" align="center" width="60"></el-table-column>
<el-table-column prop="nodeName" label="任务名称" sortable align="center"></el-table-column>
<el-table-column prop="approveName" :show-overflow-tooltip="true" label="办理人" sortable align="center">
<template #default="scope">
<template v-if="scope.row.approveName">
<el-tag v-for="(item, index) in scope.row.approveName.split(',')" :key="index" type="success">{{ item }}</el-tag>
</template>
<template v-else> <el-tag type="success"></el-tag></template>
</template>
</el-table-column>
<el-table-column prop="flowStatus" label="状态" width="80" sortable align="center">
<template #default="scope">
<dict-tag :options="wf_task_status" :value="scope.row.flowStatus"></dict-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="审批意见" :show-overflow-tooltip="true" sortable align="center"></el-table-column>
<el-table-column prop="createTime" label="开始时间" width="160" :show-overflow-tooltip="true" sortable align="center"></el-table-column>
<el-table-column prop="updateTime" label="结束时间" width="160" :show-overflow-tooltip="true" sortable align="center"></el-table-column>
<el-table-column
prop="runDuration"
label="运行时长"
width="140"
:show-overflow-tooltip="true"
sortable
align="center"
></el-table-column>
<el-table-column prop="attachmentList" width="120" label="附件" align="center">
<template #default="scope">
<el-popover v-if="scope.row.attachmentList && scope.row.attachmentList.length > 0" placement="right" :width="310" trigger="click">
<template #reference>
<el-button type="primary" style="margin-right: 16px">附件</el-button>
</template>
<el-table border :data="scope.row.attachmentList">
<el-table-column prop="originalName" width="202" :show-overflow-tooltip="true" label="附件名称"></el-table-column>
<el-table-column prop="name" width="80" align="center" :show-overflow-tooltip="true" label="操作">
<template #default="tool">
<el-button type="text" @click="handleDownload(tool.row.ossId)">下载</el-button>
</template>
</el-table-column>
</el-table>
</el-popover>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
</el-tabs>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { flowHisTaskList } from '@/api/workflow/instance';
import { propTypes } from '@/utils/propTypes';
import { listByIds } from '@/api/system/oss';
import FlowChart from '@/components/Process/flowChart.vue';
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const { wf_task_status } = toRefs<any>(proxy?.useDict('wf_task_status'));
const props = defineProps({
width: propTypes.string.def('80%'),
height: propTypes.string.def('100%')
});
const loading = ref(false);
const visible = ref(false);
const historyList = ref<Array<any>>([]);
const tabActiveName = ref('image');
const insId = ref(null);
//初始化查询审批记录
const init = async (businessId: string | number) => {
console.log(323232);
visible.value = true;
loading.value = true;
tabActiveName.value = 'image';
historyList.value = [];
console.log('🚀 ~ init ~ businessId:', businessId);
flowHisTaskList(businessId).then((resp) => {
if (resp.data) {
historyList.value = resp.data.list;
insId.value = resp.data.instanceId;
if (historyList.value.length > 0) {
historyList.value.forEach((item) => {
if (item.ext) {
getIds(item.ext).then((res) => {
item.attachmentList = res.data;
});
} else {
item.attachmentList = [];
}
});
}
loading.value = false;
}
});
};
const getIds = async (ids: string | number) => {
const res = await listByIds(ids);
return res;
};
/** 下载按钮操作 */
const handleDownload = (ossId: string) => {
proxy?.$download.oss(ossId);
};
/**
* 对外暴露子组件方法
*/
defineExpose({
init
});
</script>
<style lang="scss" scoped>
.container {
:deep(.el-dialog .el-dialog__body) {
max-height: calc(100vh - 170px) !important;
min-height: calc(100vh - 170px) !important;
}
}
</style>

View File

@ -0,0 +1,41 @@
<template>
<div>
<div style="height: 68vh" class="iframe-wrapper">
<iframe :src="iframeUrl" style="width: 100%; height: 100%" frameborder="0" scrolling="no" class="custom-iframe" />
</div>
</div>
</template>
<script setup lang="ts">
import { getToken } from '@/utils/auth';
// Props 定义方式变化
const props = defineProps({
insId: {
type: [String, Number],
default: null
}
});
const iframeUrl = ref('');
const baseUrl = import.meta.env.VITE_APP_BASE_API;
onMounted(async () => {
const url = baseUrl + `/warm-flow-ui/index.html?id=${props.insId}&type=FlowChart&t=${Date.now()}`;
iframeUrl.value = url + '&Authorization=Bearer ' + getToken() + '&clientid=' + import.meta.env.VITE_APP_CLIENT_ID;
console.log('🚀 ~ iframeUrl.value:', iframeUrl.value);
});
</script>
<style scoped>
.iframe-wrapper {
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.custom-iframe {
width: 100%;
border: none;
background: transparent;
}
</style>

View File

@ -0,0 +1,154 @@
<template>
<div
ref="imageWrapperRef"
class="image-wrapper"
@wheel="handleMouseWheel"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@mouseleave="handleMouseLeave"
@dblclick="resetTransform"
:style="transformStyle"
>
<el-card class="box-card">
<el-image :src="props.imgUrl" class="scalable-image" />
</el-card>
</div>
</template>
<script setup lang="ts">
// Props 定义方式变化
const props = defineProps({
imgUrl: {
type: String,
default: () => ''
}
});
const imageWrapperRef = ref<HTMLElement | null>(null);
const scale = ref(1); // 初始缩放比例
const maxScale = 3; // 最大缩放比例
const minScale = 0.5; // 最小缩放比例
let isDragging = false;
let startX = 0;
let startY = 0;
let currentTranslateX = 0;
let currentTranslateY = 0;
const handleMouseWheel = (event: WheelEvent) => {
event.preventDefault();
let newScale = scale.value - event.deltaY / 1000;
newScale = Math.max(minScale, Math.min(newScale, maxScale));
if (newScale !== scale.value) {
scale.value = newScale;
resetDragPosition(); // 重置拖拽位置,使图片居中
}
};
const handleMouseDown = (event: MouseEvent) => {
if (scale.value > 1) {
event.preventDefault(); // 阻止默认行为,防止拖拽
isDragging = true;
startX = event.clientX;
startY = event.clientY;
}
};
const handleMouseMove = (event: MouseEvent) => {
if (!isDragging || !imageWrapperRef.value) return;
const deltaX = event.clientX - startX;
const deltaY = event.clientY - startY;
startX = event.clientX;
startY = event.clientY;
currentTranslateX += deltaX;
currentTranslateY += deltaY;
// 边界检测,防止图片被拖出容器
const bounds = getBounds();
if (currentTranslateX > bounds.maxTranslateX) {
currentTranslateX = bounds.maxTranslateX;
} else if (currentTranslateX < bounds.minTranslateX) {
currentTranslateX = bounds.minTranslateX;
}
if (currentTranslateY > bounds.maxTranslateY) {
currentTranslateY = bounds.maxTranslateY;
} else if (currentTranslateY < bounds.minTranslateY) {
currentTranslateY = bounds.minTranslateY;
}
applyTransform();
};
const handleMouseUp = () => {
isDragging = false;
};
const handleMouseLeave = () => {
isDragging = false;
};
const resetTransform = () => {
scale.value = 1;
currentTranslateX = 0;
currentTranslateY = 0;
applyTransform();
};
const resetDragPosition = () => {
currentTranslateX = 0;
currentTranslateY = 0;
applyTransform();
};
const applyTransform = () => {
if (imageWrapperRef.value) {
imageWrapperRef.value.style.transform = `translate(${currentTranslateX}px, ${currentTranslateY}px) scale(${scale.value})`;
}
};
const getBounds = () => {
if (!imageWrapperRef.value) return { minTranslateX: 0, maxTranslateX: 0, minTranslateY: 0, maxTranslateY: 0 };
const imgRect = imageWrapperRef.value.getBoundingClientRect();
const containerRect = imageWrapperRef.value.parentElement?.getBoundingClientRect() ?? imgRect;
const minTranslateX = (containerRect.width - imgRect.width * scale.value) / 2;
const maxTranslateX = -(containerRect.width - imgRect.width * scale.value) / 2;
const minTranslateY = (containerRect.height - imgRect.height * scale.value) / 2;
const maxTranslateY = -(containerRect.height - imgRect.height * scale.value) / 2;
return { minTranslateX, maxTranslateX, minTranslateY, maxTranslateY };
};
const transformStyle = computed(() => ({
transition: isDragging ? 'none' : 'transform 0.2s ease'
}));
</script>
<style scoped>
.image-wrapper {
width: 100%;
overflow: hidden;
position: relative;
margin: 0 auto;
display: flex;
justify-content: center;
align-items: center;
user-select: none; /* 禁用文本选择 */
cursor: grab; /* 设置初始鼠标指针为可拖动 */
}
.image-wrapper:active {
cursor: grabbing; /* 当正在拖动时改变鼠标指针 */
}
.scalable-image {
object-fit: contain;
width: 100%;
padding: 15px;
}
</style>

View File

@ -0,0 +1,211 @@
<template>
<el-dialog v-model="visible" draggable title="流程干预" :width="props.width" :height="props.height" :close-on-click-modal="false">
<el-descriptions v-loading="loading" class="margin-top" :title="`${task.flowName}(${task.flowCode})`" :column="2" border>
<el-descriptions-item label="任务名称">{{ task.nodeName }}</el-descriptions-item>
<el-descriptions-item label="节点编码">{{ task.nodeCode }}</el-descriptions-item>
<el-descriptions-item label="开始时间">{{ task.createTime }}</el-descriptions-item>
<el-descriptions-item label="流程实例ID">{{ task.instanceId }}</el-descriptions-item>
<el-descriptions-item label="版本号">{{ task.version }}.0</el-descriptions-item>
<el-descriptions-item label="业务ID">{{ task.businessId }}</el-descriptions-item>
</el-descriptions>
<template #footer>
<span class="dialog-footer">
<el-button v-if="task.flowStatus === 'waiting'" :disabled="buttonDisabled" type="primary" @click="openTransferTask"> 转办 </el-button>
<el-button
v-if="task.flowStatus === 'waiting' && Number(task.nodeRatio) > 0"
:disabled="buttonDisabled"
type="primary"
@click="openMultiInstanceUser"
>
加签
</el-button>
<el-button
v-if="task.flowStatus === 'waiting' && Number(task.nodeRatio) > 0"
:disabled="buttonDisabled"
type="primary"
@click="handleTaskUser"
>
减签
</el-button>
<el-button v-if="task.flowStatus === 'waiting'" :disabled="buttonDisabled" type="danger" @click="handleTerminationTask"> 终止 </el-button>
</span>
</template>
<!-- 转办 -->
<UserSelect ref="transferTaskRef" :multiple="false" @confirm-call-back="handleTransferTask"></UserSelect>
<!-- 加签组件 -->
<UserSelect ref="multiInstanceUserRef" :multiple="true" @confirm-call-back="addMultiInstanceUser"></UserSelect>
<el-dialog v-model="deleteSignatureVisible" draggable title="减签人员" width="700px" height="400px" append-to-body :close-on-click-modal="false"
><div>
<el-table :data="deleteUserList" border>
<el-table-column prop="nodeName" label="任务名称" />
<el-table-column prop="nickName" label="办理人" />
<el-table-column label="操作" align="center" width="160">
<template #default="scope">
<el-button type="danger" size="small" icon="Delete" @click="deleteMultiInstanceUser(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-dialog>
</el-dialog>
</template>
<script setup lang="ts">
import { propTypes } from '@/utils/propTypes';
import { FlowTaskVO, TaskOperationBo } from '@/api/workflow/task/types';
import UserSelect from '@/components/UserSelect';
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
import { getTask, taskOperation, currentTaskAllUser, terminationTask } from '@/api/workflow/task';
const props = defineProps({
width: propTypes.string.def('50%'),
height: propTypes.string.def('100%')
});
const emits = defineEmits(['submitCallback']);
const transferTaskRef = ref<InstanceType<typeof UserSelect>>();
const multiInstanceUserRef = ref<InstanceType<typeof UserSelect>>();
//遮罩层
const loading = ref(true);
//按钮
const buttonDisabled = ref(true);
const visible = ref(false);
//减签弹窗
const deleteSignatureVisible = ref(false);
//可减签的人员
const deleteUserList = ref<any>([]);
//任务
const task = ref<FlowTaskVO>({
id: undefined,
createTime: undefined,
updateTime: undefined,
tenantId: undefined,
definitionId: undefined,
instanceId: undefined,
flowName: undefined,
businessId: undefined,
nodeCode: undefined,
nodeName: undefined,
flowCode: undefined,
flowStatus: undefined,
formCustom: undefined,
formPath: undefined,
nodeType: undefined,
nodeRatio: undefined,
version: undefined,
applyNode: undefined,
buttonList: []
});
const open = (taskId: string) => {
visible.value = true;
getTask(taskId).then((response) => {
loading.value = false;
buttonDisabled.value = false;
task.value = response.data;
});
};
//打开转办
const openTransferTask = () => {
transferTaskRef.value.open();
};
//转办
const handleTransferTask = async (data) => {
if (data && data.length > 0) {
const taskOperationBo = reactive<TaskOperationBo>({
userId: data[0].userId,
taskId: task.value.id,
message: ''
});
await proxy?.$modal.confirm('是否确认提交?');
loading.value = true;
buttonDisabled.value = true;
await taskOperation(taskOperationBo, 'transferTask').finally(() => {
loading.value = false;
buttonDisabled.value = false;
});
visible.value = false;
emits('submitCallback');
proxy?.$modal.msgSuccess('操作成功');
} else {
proxy?.$modal.msgWarning('请选择用户!');
}
};
//加签
const openMultiInstanceUser = async () => {
multiInstanceUserRef.value.open();
};
//加签
const addMultiInstanceUser = async (data) => {
if (data && data.length > 0) {
const taskOperationBo = reactive<TaskOperationBo>({
userIds: data.map((e) => e.userId),
taskId: task.value.id,
message: ''
});
await proxy?.$modal.confirm('是否确认提交?');
loading.value = true;
buttonDisabled.value = true;
await taskOperation(taskOperationBo, 'addSignature').finally(() => {
loading.value = false;
buttonDisabled.value = false;
});
visible.value = false;
emits('submitCallback');
proxy?.$modal.msgSuccess('操作成功');
} else {
proxy?.$modal.msgWarning('请选择用户!');
}
};
//减签
const deleteMultiInstanceUser = async (row) => {
await proxy?.$modal.confirm('是否确认提交?');
loading.value = true;
buttonDisabled.value = true;
const taskOperationBo = reactive<TaskOperationBo>({
userIds: [row.userId],
taskId: task.value.id,
message: ''
});
await taskOperation(taskOperationBo, 'reductionSignature').finally(() => {
loading.value = false;
buttonDisabled.value = false;
});
visible.value = false;
emits('submitCallback');
proxy?.$modal.msgSuccess('操作成功');
};
//获取办理人
const handleTaskUser = async () => {
const data = await currentTaskAllUser(task.value.id);
deleteUserList.value = data.data;
if (deleteUserList.value && deleteUserList.value.length > 0) {
deleteUserList.value.forEach((e) => {
e.nodeName = task.value.nodeName;
});
}
deleteSignatureVisible.value = true;
};
//终止任务
const handleTerminationTask = async () => {
const params = {
taskId: task.value.id,
comment: ''
};
await proxy?.$modal.confirm('是否确认终止?');
loading.value = true;
buttonDisabled.value = true;
await terminationTask(params).finally(() => {
loading.value = false;
buttonDisabled.value = false;
});
visible.value = false;
emits('submitCallback');
proxy?.$modal.msgSuccess('操作成功');
};
/**
* 对外暴露子组件方法
*/
defineExpose({
open
});
</script>

View File

@ -0,0 +1,584 @@
<template>
<el-dialog v-model="dialog.visible" :title="dialog.title" width="50%" draggable :before-close="cancel" center :close-on-click-modal="false">
<el-form v-loading="loading" :model="form" label-width="120px">
<el-form-item label="消息提醒">
<el-checkbox-group v-model="form.messageType">
<el-checkbox value="1" name="type" disabled>站内信</el-checkbox>
<el-checkbox value="2" name="type">邮件</el-checkbox>
<el-checkbox value="3" name="type">短信</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="附件">
<fileUpload v-model="form.fileId" :file-type="['png', 'jpg', 'jpeg', 'doc', 'docx', 'xlsx', 'xls', 'ppt', 'txt', 'pdf']" :file-size="20" />
</el-form-item>
<el-form-item label="抄送" v-if="buttonObj.copy">
<el-button type="primary" icon="Plus" circle @click="openUserSelectCopy" />
<el-tag v-for="user in selectCopyUserList" :key="user.userId" closable style="margin: 2px" @close="handleCopyCloseTag(user)">
{{ user.nickName }}
</el-tag>
</el-form-item>
<el-form-item v-if="buttonObj.pop && nestNodeList && nestNodeList.length > 0" label="下一步审批人" prop="assigneeMap">
<div v-for="(item, index) in nestNodeList" :key="index" style="margin-bottom: 5px; width: 500px">
<span>{{ item.nodeName }}</span>
<el-input v-if="false" v-model="form.assigneeMap[item.nodeCode]" />
<el-input placeholder="请选择审批人" readonly v-model="nickName[item.nodeCode]">
<template v-slot:append>
<el-button @click="choosePeople(item)" icon="search">选择</el-button>
</template>
</el-input>
</div>
</el-form-item>
<el-form-item v-if="task.flowStatus === 'waiting'" label="审批意见">
<el-input v-model="form.message" type="textarea" resize="none" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button :disabled="buttonDisabled" type="primary" @click="handleCompleteTask"> 提交 </el-button>
<el-button v-if="task.flowStatus === 'waiting' && buttonObj.trust" :disabled="buttonDisabled" type="primary" @click="openDelegateTask">
委托
</el-button>
<el-button v-if="task.flowStatus === 'waiting' && buttonObj.transfer" :disabled="buttonDisabled" type="primary" @click="openTransferTask">
转办
</el-button>
<el-button
v-if="task.flowStatus === 'waiting' && Number(task.nodeRatio) > 0 && buttonObj.addSign"
:disabled="buttonDisabled"
type="primary"
@click="openMultiInstanceUser"
>
加签
</el-button>
<el-button
v-if="task.flowStatus === 'waiting' && Number(task.nodeRatio) > 0 && buttonObj.subSign"
:disabled="buttonDisabled"
type="primary"
@click="handleTaskUser"
>
减签
</el-button>
<el-button
v-if="task.flowStatus === 'waiting' && buttonObj.termination"
:disabled="buttonDisabled"
type="danger"
@click="handleTerminationTask"
>
终止
</el-button>
<el-button v-if="task.flowStatus === 'waiting' && buttonObj.back" :disabled="buttonDisabled" type="danger" @click="handleBackProcessOpen">
退回
</el-button>
<el-button :disabled="buttonDisabled" @click="cancel">取消</el-button>
</span>
</template>
<!-- 抄送 -->
<UserSelect ref="userSelectCopyRef" :multiple="true" :data="selectCopyUserIds" @confirm-call-back="userSelectCopyCallBack"></UserSelect>
<!-- 转办 -->
<UserSelect ref="transferTaskRef" :multiple="false" @confirm-call-back="handleTransferTask"></UserSelect>
<!-- 委托 -->
<UserSelect ref="delegateTaskRef" :multiple="false" @confirm-call-back="handleDelegateTask"></UserSelect>
<!-- 加签组件 -->
<UserSelect ref="multiInstanceUserRef" :multiple="true" @confirm-call-back="addMultiInstanceUser"></UserSelect>
<!-- 弹窗选人 -->
<UserSelect ref="porUserRef" :multiple="true" :userIds="popUserIds" @confirm-call-back="handlePopUser"></UserSelect>
<!-- 驳回开始 -->
<el-dialog v-model="backVisible" draggable title="驳回" :width="isDrawing ? '800px' : '40%'" :close-on-click-modal="false">
<el-form v-if="task.flowStatus === 'waiting'" v-loading="backLoading" :model="backForm" label-width="120px">
<el-form-item label="驳回节点">
<el-select v-model="backForm.nodeCode" clearable placeholder="请选择" style="width: 300px">
<el-option v-for="item in taskNodeList" :key="item.nodeCode" :label="item.nodeName" :value="item.nodeCode" />
</el-select>
</el-form-item>
<el-form-item label="消息提醒">
<el-checkbox-group v-model="backForm.messageType">
<el-checkbox label="1" name="type" disabled>站内信</el-checkbox>
<el-checkbox label="2" name="type">邮件</el-checkbox>
<el-checkbox label="3" name="type">短信</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item v-if="task.flowStatus === 'waiting'" label="附件">
<fileUpload
v-model="backForm.fileId"
:file-type="['png', 'jpg', 'jpeg', 'doc', 'docx', 'xlsx', 'xls', 'ppt', 'txt', 'pdf']"
:file-size="20"
/>
</el-form-item>
<el-form-item label="审批意见">
<el-input v-model="backForm.message" type="textarea" resize="none" />
</el-form-item>
</el-form>
<div class="box" v-if="isDrawing">
<span>设计验证</span>
<detailForm ref="detailFormRef"></detailForm>
</div>
<template #footer>
<div class="dialog-footer" style="float: right; padding-bottom: 20px">
<el-button :disabled="backButtonDisabled" type="primary" @click="handleBackProcess">确认</el-button>
<el-button :disabled="backButtonDisabled" @click="backVisible = false">取消</el-button>
</div>
</template>
</el-dialog>
<!-- 驳回结束 -->
<el-dialog v-model="deleteSignatureVisible" draggable title="减签人员" width="700px" height="400px" append-to-body :close-on-click-modal="false">
<div>
<el-table :data="deleteUserList" border>
<el-table-column prop="nodeName" label="任务名称" />
<el-table-column prop="nickName" label="办理人" />
<el-table-column label="操作" align="center" width="160">
<template #default="scope">
<el-button type="danger" size="small" icon="Delete" @click="deleteMultiInstanceUser(scope.row)">删除 </el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-dialog>
<el-dialog v-model="isShowTermination" title="终止任务" width="800px" draggable :close-on-click-modal="false">
<detailForm ref="detailFormTeRef"></detailForm>
<template #footer>
<div class="dialog-footer" style="float: right; padding-bottom: 20px">
<el-button type="primary" @click="handleTermination">确认</el-button>
<el-button @click="isShowTermination = false">取消</el-button>
</div>
</template>
</el-dialog>
</el-dialog>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ComponentInternalInstance } from 'vue';
import { ElForm } from 'element-plus';
import {
completeTask,
backProcess,
getTask,
taskOperation,
terminationTask,
getBackTaskNode,
currentTaskAllUser,
getNextNodeList
} from '@/api/workflow/task';
import UserSelect from '@/components/UserSelect';
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
import { UserVO } from '@/api/system/user/types';
import { FlowTaskVO, TaskOperationBo } from '@/api/workflow/task/types';
import detailForm from '@/views/design/drawingreview/detailForm.vue';
const userSelectCopyRef = ref<InstanceType<typeof UserSelect>>();
const transferTaskRef = ref<InstanceType<typeof UserSelect>>();
const delegateTaskRef = ref<InstanceType<typeof UserSelect>>();
const multiInstanceUserRef = ref<InstanceType<typeof UserSelect>>();
const porUserRef = ref<InstanceType<typeof UserSelect>>();
const detailFormRef = ref<InstanceType<typeof detailForm>>();
const detailFormTeRef = ref<InstanceType<typeof detailForm>>();
const props = defineProps({
taskVariables: {
type: Object as () => Record<string, any>,
default: () => {}
}
});
//遮罩层
const loading = ref(true);
//按钮
const buttonDisabled = ref(true);
//任务id
const taskId = ref<string>('');
//抄送人
const selectCopyUserList = ref<UserVO[]>([]);
//抄送人id
const selectCopyUserIds = ref<string>(undefined);
//可减签的人员
const deleteUserList = ref<any>([]);
//弹窗可选择的人员id
const popUserIds = ref<any>([]);
//驳回是否显示
const backVisible = ref(false);
const backLoading = ref(true);
const backButtonDisabled = ref(true);
// 可驳回得任务节点
const taskNodeList = ref([]);
const nickName = ref({});
const isDrawing = ref(false); //图纸评审标志
const businessId = ref(''); //业务id
const isShowTermination = ref(false); //显示终止
//节点编码
const nodeCode = ref<string>('');
const buttonObj = ref<any>({
pop: false,
trust: false,
transfer: false,
addSign: false,
subSign: false,
termination: false,
back: false
});
//下一节点列表
const nestNodeList = ref([]);
//任务
const task = ref<FlowTaskVO>({
id: undefined,
createTime: undefined,
updateTime: undefined,
tenantId: undefined,
definitionId: undefined,
instanceId: undefined,
flowName: undefined,
businessId: undefined,
nodeCode: undefined,
nodeName: undefined,
flowCode: undefined,
flowStatus: undefined,
formCustom: undefined,
formPath: undefined,
nodeType: undefined,
nodeRatio: undefined,
applyNode: false,
buttonList: []
});
const dialog = reactive<DialogOption>({
visible: false,
title: '提示'
});
//减签弹窗
const deleteSignatureVisible = ref(false);
const form = ref<Record<string, any>>({
taskId: undefined,
message: undefined,
assigneeMap: {},
variables: {},
messageType: ['1'],
flowCopyList: []
});
const backForm = ref<Record<string, any>>({
taskId: undefined,
nodeCode: undefined,
message: undefined,
variables: {},
messageType: ['1']
});
//打开弹窗
const openDialog = async (id?: string, Drawing?: boolean, Id?: string) => {
businessId.value = Id || '';
isDrawing.value = Drawing;
selectCopyUserIds.value = undefined;
selectCopyUserList.value = [];
form.value.fileId = undefined;
taskId.value = id;
form.value.message = undefined;
dialog.visible = true;
loading.value = true;
buttonDisabled.value = true;
const response = await getTask(taskId.value);
task.value = response.data;
buttonObj.value = {};
task.value.buttonList.forEach((e) => {
buttonObj.value[e.code] = e.show;
});
buttonDisabled.value = false;
const data = {
taskId: taskId.value,
variables: props.taskVariables
};
const nextData = await getNextNodeList(data);
nestNodeList.value = nextData.data;
loading.value = false;
};
onMounted(() => {});
const emits = defineEmits(['submitCallback', 'cancelCallback']);
/** 办理流程 */
const handleCompleteTask = async () => {
form.value.taskId = taskId.value;
form.value.variables = props.taskVariables;
let verify = false;
if (buttonObj.value.pop && nestNodeList.value && nestNodeList.value.length > 0) {
nestNodeList.value.forEach((e) => {
if (
Object.keys(form.value.assigneeMap).length === 0 ||
form.value.assigneeMap[e.nodeCode] === '' ||
form.value.assigneeMap[e.nodeCode] === null ||
form.value.assigneeMap[e.nodeCode] === undefined
) {
verify = true;
}
});
if (verify) {
proxy?.$modal.msgWarning('请选择审批人!');
return false;
}
} else {
form.value.assigneeMap = {};
}
if (selectCopyUserList.value && selectCopyUserList.value.length > 0) {
const flowCopyList = [];
selectCopyUserList.value.forEach((e) => {
const copyUser = {
userId: e.userId,
userName: e.nickName
};
flowCopyList.push(copyUser);
});
form.value.flowCopyList = flowCopyList;
}
await proxy?.$modal.confirm('是否确认提交?');
loading.value = true;
buttonDisabled.value = true;
try {
await completeTask(form.value);
dialog.visible = false;
emits('submitCallback');
proxy?.$modal.msgSuccess('操作成功');
} finally {
loading.value = false;
buttonDisabled.value = false;
}
};
/** 驳回弹窗打开 */
const handleBackProcessOpen = async () => {
backForm.value = {};
backForm.value.messageType = ['1'];
backVisible.value = true;
backLoading.value = true;
backButtonDisabled.value = true;
const data = await getBackTaskNode(task.value.definitionId, task.value.nodeCode);
// 调用子组件
taskNodeList.value = data.data;
backLoading.value = false;
backButtonDisabled.value = false;
backForm.value.nodeCode = taskNodeList.value[0].nodeCode;
};
/** 驳回流程 */
const handleBackProcess = async () => {
backForm.value.taskId = taskId.value;
await proxy?.$modal.confirm('是否确认驳回到申请人?');
loading.value = true;
backLoading.value = true;
backButtonDisabled.value = true;
await backProcess(backForm.value).finally(() => {
loading.value = false;
buttonDisabled.value = false;
if (isDrawing.value) {
detailFormRef.value.submit(businessId.value);
}
});
dialog.visible = false;
backLoading.value = false;
backButtonDisabled.value = false;
emits('submitCallback');
proxy?.$modal.msgSuccess('操作成功');
};
//取消
const cancel = async () => {
dialog.visible = false;
buttonDisabled.value = false;
nickName.value = {};
form.value.assigneeMap = {};
emits('cancelCallback');
};
//打开抄送人员
const openUserSelectCopy = () => {
userSelectCopyRef.value.open();
};
//确认抄送人员
const userSelectCopyCallBack = (data: UserVO[]) => {
if (data && data.length > 0) {
selectCopyUserList.value = data;
selectCopyUserIds.value = selectCopyUserList.value.map((item) => item.userId).join(',');
}
};
//删除抄送人员
const handleCopyCloseTag = (user: UserVO) => {
const userId = user.userId;
// 使用split删除用户
const index = selectCopyUserList.value.findIndex((item) => item.userId === userId);
selectCopyUserList.value.splice(index, 1);
selectCopyUserIds.value = selectCopyUserList.value.map((item) => item.userId).join(',');
};
//加签
const openMultiInstanceUser = async () => {
multiInstanceUserRef.value.open();
};
//加签
const addMultiInstanceUser = async (data) => {
if (data && data.length > 0) {
const taskOperationBo = reactive<TaskOperationBo>({
userIds: data.map((e) => e.userId),
taskId: taskId.value,
message: form.value.message
});
await proxy?.$modal.confirm('是否确认提交?');
loading.value = true;
buttonDisabled.value = true;
await taskOperation(taskOperationBo, 'addSignature').finally(() => {
loading.value = false;
buttonDisabled.value = false;
});
dialog.visible = false;
emits('submitCallback');
proxy?.$modal.msgSuccess('操作成功');
} else {
proxy?.$modal.msgWarning('请选择用户!');
}
};
//减签
const deleteMultiInstanceUser = async (row) => {
await proxy?.$modal.confirm('是否确认提交?');
loading.value = true;
buttonDisabled.value = true;
const taskOperationBo = reactive<TaskOperationBo>({
userIds: [row.userId],
taskId: taskId.value,
message: form.value.message
});
await taskOperation(taskOperationBo, 'reductionSignature').finally(() => {
loading.value = false;
buttonDisabled.value = false;
});
dialog.visible = false;
emits('submitCallback');
proxy?.$modal.msgSuccess('操作成功');
};
//打开转办
const openTransferTask = () => {
transferTaskRef.value.open();
};
//转办
const handleTransferTask = async (data) => {
if (data && data.length > 0) {
const taskOperationBo = reactive<TaskOperationBo>({
userId: data[0].userId,
taskId: taskId.value,
message: form.value.message
});
await proxy?.$modal.confirm('是否确认提交?');
loading.value = true;
buttonDisabled.value = true;
await taskOperation(taskOperationBo, 'transferTask').finally(() => {
loading.value = false;
buttonDisabled.value = false;
});
dialog.visible = false;
emits('submitCallback');
proxy?.$modal.msgSuccess('操作成功');
} else {
proxy?.$modal.msgWarning('请选择用户!');
}
};
//打开委托
const openDelegateTask = () => {
delegateTaskRef.value.open();
};
//委托
const handleDelegateTask = async (data) => {
if (data && data.length > 0) {
const taskOperationBo = reactive<TaskOperationBo>({
userId: data[0].userId,
taskId: taskId.value,
message: form.value.message
});
await proxy?.$modal.confirm('是否确认提交?');
loading.value = true;
buttonDisabled.value = true;
await taskOperation(taskOperationBo, 'delegateTask').finally(() => {
loading.value = false;
buttonDisabled.value = false;
});
dialog.visible = false;
emits('submitCallback');
proxy?.$modal.msgSuccess('操作成功');
} else {
proxy?.$modal.msgWarning('请选择用户!');
}
};
const handleTermination = async () => {
// 终止任务
const params = {
taskId: taskId.value,
comment: form.value.message
};
await terminationTask(params).finally(() => {
// 提交设计验证
detailFormTeRef.value.submit(businessId.value);
loading.value = false;
buttonDisabled.value = false;
});
dialog.visible = false;
emits('submitCallback');
proxy?.$modal.msgSuccess('操作成功');
};
//终止任务
const handleTerminationTask = async () => {
if (isDrawing.value) {
isShowTermination.value = true;
return;
}
const params = {
taskId: taskId.value,
comment: form.value.message
};
await proxy?.$modal.confirm('是否确认终止?');
loading.value = true;
buttonDisabled.value = true;
await terminationTask(params).finally(() => {
loading.value = false;
buttonDisabled.value = false;
});
dialog.visible = false;
emits('submitCallback');
proxy?.$modal.msgSuccess('操作成功');
};
const handleTaskUser = async () => {
const data = await currentTaskAllUser(taskId.value);
deleteUserList.value = data.data;
if (deleteUserList.value && deleteUserList.value.length > 0) {
deleteUserList.value.forEach((e) => {
e.nodeName = task.value.nodeName;
});
}
deleteSignatureVisible.value = true;
};
// 选择人员
const choosePeople = async (data) => {
if (!data.permissionFlag) {
proxy?.$modal.msgError('没有可选择的人员,请联系管理员!');
}
popUserIds.value = data.permissionFlag;
nodeCode.value = data.nodeCode;
porUserRef.value.open();
};
//确认选择
const handlePopUser = async (userList) => {
const userIds = userList.map((item) => {
return item.userId;
});
const nickNames = userList.map((item) => {
return item.nickName;
});
form.value.assigneeMap[nodeCode.value] = userIds.join(',');
nickName.value[nodeCode.value] = nickNames.join(',');
};
/**
* 对外暴露子组件方法
*/
defineExpose({
openDialog
});
</script>
<style lang="scss">
.box {
border: 1px solid #f1f1f1;
padding: 16px;
border-radius: 10px;
> span {
font-size: 14px;
font-weight: bold;
color: #0090f9;
}
}
</style>

View File

@ -0,0 +1,108 @@
<template>
<div class="select-container">
<label for="projectSelect" class="select-label">项目列表:</label>
<el-select
id="projectSelect"
v-model="selectedProjectId"
placeholder="全部工程项目"
clearable
filterable
@change="handleSelect"
style="width: 150px; margin-right: 20px"
>
<el-option v-for="project in projects" :key="project.id" :label="project.name" :value="project.id" />
</el-select>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, watch } from 'vue';
import { useUserStore } from '@/store/modules/user';
import { getProjectTeam } from '@/utils/projectTeam';
const userStore = useUserStore();
const projects = computed(() => [
// { id: '', name: '全部工程项目' }, // 添加空选项
...userStore.projects
]);
const selectedProjectId = ref(userStore.selectedProject?.id || '');
// 监听 userStore.selectedProject 变化,更新 selectedProjectId
watch(
() => userStore.selectedProject,
(newProject) => {
selectedProjectId.value = newProject?.id ?? ''; // 避免 undefined 导致 placeholder 显示
},
{ deep: true }
);
const handleSelect = (projectId: string) => {
const selectedProject = projects.value.find((p) => p.id === projectId);
if (selectedProject) {
userStore.setSelectedProject(selectedProject);
console.log(userStore.selectedProject); // 打印选中的项目
}
};
</script>
<style lang="scss" scoped>
.select-container {
display: flex;
align-items: center; // 上下居中对齐
gap: 10px; // label 和 select 之间的间距
}
.select-label {
font-weight: bold;
color: #333;
white-space: nowrap; // 防止 label 换行
font-size: 14px; // 设置字体大小
}
#projectSelect {
.el-select {
width: 400px; // 保持宽度
.el-input__inner {
height: 38px;
border-radius: 4px;
border: 1px solid #dcdfe6;
transition: border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
&:hover {
border-color: #409eff;
}
&:focus {
border-color: #409eff;
box-shadow: 0 0 5px rgba(64, 158, 255, 0.3);
}
}
.el-input__icon {
line-height: 38px;
}
}
&.is-focus .el-input__inner {
border-color: #409eff;
}
}
// 响应式设计(可选)
@media (max-width: 768px) {
.select-container {
flex-direction: column; // 栈式布局
align-items: flex-start; // 左对齐
.select-label {
margin-bottom: 5px; // label 和 select 之间的垂直间距
}
#projectSelect .el-select {
width: 100%; // 在小屏幕上占满宽度
}
}
}
</style>

View File

@ -0,0 +1,102 @@
<template>
<div class="top-right-btn" :style="style">
<el-row>
<el-tooltip v-if="search" class="item" effect="dark" :content="showSearch ? '隐藏搜索' : '显示搜索'" placement="top">
<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 v-if="columns" class="item" effect="dark" content="显示/隐藏列" placement="top">
<div class="show-btn">
<el-popover placement="bottom" trigger="click">
<div class="tree-header">显示/隐藏列</div>
<el-tree
ref="columnRef"
:data="columns"
show-checkbox
node-key="key"
:props="{ label: 'label', children: 'children' }"
@check="columnChange"
></el-tree>
<template #reference>
<el-button circle icon="Menu" />
</template>
</el-popover>
</div>
</el-tooltip>
</el-row>
</div>
</template>
<script setup lang="ts">
import { propTypes } from '@/utils/propTypes';
const props = defineProps({
showSearch: propTypes.bool.def(true),
columns: propTypes.fieldOption,
search: propTypes.bool.def(true),
gutter: propTypes.number.def(10)
});
const columnRef = ref<ElTreeInstance>();
const emits = defineEmits(['update:showSearch', 'queryTable']);
const style = computed(() => {
const ret: any = {};
if (props.gutter) {
ret.marginRight = `${props.gutter / 2}px`;
}
return ret;
});
// 搜索
function toggleSearch() {
emits('update:showSearch', !props.showSearch);
}
// 刷新
function refresh() {
emits('queryTable');
}
// 更改数据列的显示和隐藏
function columnChange(...args: any[]) {
props.columns?.forEach((item) => {
item.visible = args[1].checkedKeys.includes(item.key);
});
}
// 显隐列初始默认隐藏列
onMounted(() => {
props.columns?.forEach((item) => {
if (item.visible) {
columnRef.value?.setChecked(item.key, true, false);
// value.value.push(item.key);
}
});
});
</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;
}
.tree-header {
width: 100%;
line-height: 24px;
text-align: center;
}
.show-btn {
margin-left: 12px;
}
</style>

View File

@ -0,0 +1,250 @@
<template>
<div>
<el-dialog v-model="roleDialog.visible.value" :title="roleDialog.title.value" width="80%" append-to-body>
<transition :enter-active-class="proxy?.animate.searchAnimate.enter" :leave-active-class="proxy?.animate.searchAnimate.leave">
<div v-show="showSearch" class="mb-[10px]">
<el-card shadow="hover">
<el-form ref="queryFormRef" :model="queryParams" :inline="true">
<el-form-item label="角色名称" prop="roleName">
<el-input v-model="queryParams.roleName" placeholder="请输入角色名称" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="权限字符" prop="roleKey">
<el-input v-model="queryParams.roleKey" placeholder="请输入权限字符" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</transition>
<el-card shadow="hover">
<template #header>
<el-tag v-for="role in selectRoleList" :key="role.roleId" closable style="margin: 2px" @close="handleCloseTag(role)">
{{ role.roleName }}
</el-tag>
</template>
<vxe-table
ref="tableRef"
height="400px"
border
show-overflow
:data="roleList"
:loading="loading"
:row-config="{ keyField: 'roleId' }"
:checkbox-config="{ reserve: true, checkRowKeys: defaultSelectRoleIds }"
highlight-current-row
@checkbox-all="handleCheckboxAll"
@checkbox-change="handleCheckboxChange"
>
<vxe-column type="checkbox" width="50" align="center" />
<vxe-column v-if="false" key="roleId" label="角色编号" />
<vxe-column field="roleName" title="角色名称" />
<vxe-column field="roleKey" title="权限字符" />
<vxe-column field="roleSort" title="显示顺序" width="100" />
<vxe-column title="状态" align="center" width="100">
<template #default="scope">
<dict-tag :options="sys_normal_disable" :value="scope.row.status"></dict-tag>
</template>
</vxe-column>
<vxe-column field="createTime" title="创建时间" align="center">
<template #default="scope">
<span>{{ proxy.parseTime(scope.row.createTime) }}</span>
</template>
</vxe-column>
</vxe-table>
<pagination
v-if="total > 0"
v-model:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="pageList"
/>
</el-card>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" @click="confirm">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { RoleVO, RoleQuery } from '@/api/system/role/types';
import { VxeTableInstance } from 'vxe-table';
import useDialog from '@/hooks/useDialog';
import api from '@/api/system/role';
interface PropType {
modelValue?: RoleVO[] | RoleVO | undefined;
multiple?: boolean;
data?: string | number | (string | number)[];
}
const prop = withDefaults(defineProps<PropType>(), {
multiple: true,
modelValue: undefined,
data: undefined
});
const emit = defineEmits(['update:modelValue', 'confirmCallBack']);
const router = useRouter();
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const { sys_normal_disable } = toRefs<any>(proxy?.useDict('sys_normal_disable'));
const roleList = ref<RoleVO[]>();
const loading = ref(true);
const showSearch = ref(true);
const total = ref(0);
const dateRange = ref<[DateModelType, DateModelType]>(['', '']);
const selectRoleList = ref<RoleVO[]>([]);
const roleDialog = useDialog({
title: '角色选择'
});
const queryFormRef = ref<ElFormInstance>();
const tableRef = ref<VxeTableInstance<RoleVO>>();
const queryParams = ref<RoleQuery>({
pageNum: 1,
pageSize: 10,
roleName: '',
roleKey: '',
status: ''
});
const defaultSelectRoleIds = computed(() => computedIds(prop.data));
const confirm = () => {
emit('update:modelValue', selectRoleList.value);
emit('confirmCallBack', selectRoleList.value);
roleDialog.closeDialog();
};
const computedIds = (data) => {
if (data instanceof Array) {
return [...data];
} else if (typeof data === 'string') {
return data.split(',');
} else if (typeof data === 'number') {
return [data];
} else {
console.warn('<RoleSelect> The data type of data should be array or string or number, but I received other');
return [];
}
};
/**
* 查询角色列表
*/
const getList = () => {
loading.value = true;
api.listRole(proxy?.addDateRange(queryParams.value, dateRange.value)).then((res) => {
roleList.value = res.rows;
total.value = res.total;
loading.value = false;
});
};
const pageList = async () => {
await getList();
const roles = roleList.value.filter((item) => {
return selectRoleList.value.some((role) => role.roleId === item.roleId);
});
await tableRef.value.setCheckboxRow(roles, true);
};
/**
* 搜索按钮操作
*/
const handleQuery = () => {
queryParams.value.pageNum = 1;
getList();
};
/** 重置 */
const resetQuery = () => {
dateRange.value = ['', ''];
queryFormRef.value?.resetFields();
handleQuery();
};
const handleCheckboxChange = (checked) => {
if (!prop.multiple && checked.checked) {
tableRef.value.setCheckboxRow(selectRoleList.value, false);
selectRoleList.value = [];
}
const row = checked.row;
if (checked.checked) {
selectRoleList.value.push(row);
} else {
selectRoleList.value = selectRoleList.value.filter((item) => {
return item.roleId !== row.roleId;
});
}
};
const handleCheckboxAll = (checked) => {
const rows = roleList.value;
if (checked.checked) {
rows.forEach((row) => {
if (!selectRoleList.value.some((item) => item.roleId === row.roleId)) {
selectRoleList.value.push(row);
}
});
} else {
selectRoleList.value = selectRoleList.value.filter((item) => {
return !rows.some((row) => row.roleId === item.roleId);
});
}
};
const handleCloseTag = (user: RoleVO) => {
const roleId = user.roleId;
// 使用split删除用户
const index = selectRoleList.value.findIndex((item) => item.roleId === roleId);
const rows = selectRoleList.value[index];
tableRef.value?.setCheckboxRow(rows, false);
selectRoleList.value.splice(index, 1);
};
/**
* 初始化选中数据
*/
const initSelectRole = async () => {
if (defaultSelectRoleIds.value.length > 0) {
const { data } = await api.optionSelect(defaultSelectRoleIds.value);
selectRoleList.value = data;
const users = roleList.value.filter((item) => {
return defaultSelectRoleIds.value.includes(String(item.roleId));
});
await nextTick(() => {
tableRef.value.setCheckboxRow(users, true);
});
}
};
const close = () => {
roleDialog.closeDialog();
};
watch(
() => roleDialog.visible.value,
(newValue: boolean) => {
if (newValue) {
initSelectRole();
} else {
tableRef.value.clearCheckboxReserve();
tableRef.value.clearCheckboxRow();
resetQuery();
selectRoleList.value = [];
}
}
);
onMounted(() => {
getList(); // 初始化列表数据
});
defineExpose({
open: roleDialog.openDialog,
close: roleDialog.closeDialog
});
</script>

View File

@ -0,0 +1,9 @@
<template>
<div>
<svg-icon :icon-class="isFullscreen ? 'exit-fullscreen' : 'fullscreen'" @click="toggle" />
</div>
</template>
<script setup lang="ts">
const { isFullscreen, toggle } = useFullscreen();
</script>

View File

@ -0,0 +1,41 @@
<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 lang="ts">
import useAppStore from '@/store/modules/app';
const appStore = useAppStore();
const size = computed(() => appStore.size);
const sizeOptions = ref([
{ label: '较大', value: 'large' },
{ label: '默认', value: 'default' },
{ label: '稍小', value: 'small' }
]);
const handleSetSize = (size: 'large' | 'default' | 'small') => {
appStore.setSize(size);
};
</script>
<style lang="scss" scoped>
.size-icon--style {
font-size: 18px;
line-height: 50px;
padding-right: 7px;
}
</style>

View File

@ -0,0 +1,40 @@
<template>
<svg :class="svgClass" aria-hidden="true">
<use :xlink:href="iconName" :fill="color" />
</svg>
</template>
<script setup lang="ts">
import { propTypes } from '@/utils/propTypes';
const props = defineProps({
iconClass: propTypes.string.isRequired,
className: propTypes.string.def(''),
color: propTypes.string.def('')
});
const iconName = computed(() => `#icon-${props.iconClass}`);
const 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,200 @@
<template>
<el-menu :default-active="activeMenu" mode="horizontal" :ellipsis="false" @select="handleSelect">
<template v-for="(item, index) in topMenus">
<el-menu-item v-if="index < visibleNumber" :key="index" :style="{ '--theme': theme }" :index="item.path">
<svg-icon v-if="item.meta && item.meta.icon && item.meta.icon !== '#'" :icon-class="item.meta ? item.meta.icon : ''" />
{{ item.meta?.title }}
</el-menu-item>
</template>
<!-- 顶部菜单超出数量折叠 -->
<el-sub-menu v-if="topMenus.length > visibleNumber" :style="{ '--theme': theme }" index="more">
<template #title>更多菜单</template>
<template v-for="(item, index) in topMenus">
<el-menu-item v-if="index >= visibleNumber" :key="index" :index="item.path"
><svg-icon :icon-class="item.meta ? item.meta.icon : ''" /> {{ item.meta?.title }}</el-menu-item
>
</template>
</el-sub-menu>
</el-menu>
</template>
<script setup lang="ts">
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';
import { RouteRecordRaw } from 'vue-router';
// 顶部栏初始数
const visibleNumber = ref<number>(-1);
// 当前激活菜单的 index
const currentIndex = ref<string>();
// 隐藏侧边栏路由
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.getTopbarRoutes());
// 顶部显示菜单
const topMenus = computed(() => {
let topMenus: RouteRecordRaw[] = [];
routers.value.map((menu) => {
if (menu.hidden !== true) {
// 兼容顶部栏一级菜单内部跳转
if (menu.path === '/') {
topMenus.push(menu.children ? menu.children[0] : menu);
} else {
topMenus.push(menu);
}
}
});
return topMenus;
});
// 设置子路由
const childrenMenus = computed(() => {
let childrenMenus: RouteRecordRaw[] = [];
routers.value.map((router) => {
router.children?.forEach((item) => {
if (item.parentPath === undefined) {
if (router.path === '/') {
item.path = '/' + item.path;
} else {
if (!isHttp(item.path)) {
item.path = router.path + '/' + item.path;
}
}
item.parentPath = router.path;
}
childrenMenus.push(item);
});
});
return constantRoutes.concat(childrenMenus);
});
// 默认激活的菜单
const activeMenu = computed(() => {
let path = route.path;
if (path === '/index') {
path = '/system/user';
}
let activePath = path;
if (path !== undefined && path.lastIndexOf('/') > 0 && hideList.indexOf(path) === -1) {
const tmpPath = path.substring(1, path.length);
if (!route.meta.link) {
activePath = '/' + tmpPath.substring(0, tmpPath.indexOf('/'));
appStore.toggleSideBarHide(false);
}
} else if (!route.children) {
activePath = path;
appStore.toggleSideBarHide(true);
}
activeRoutes(activePath);
return activePath;
});
const setVisibleNumber = () => {
const width = document.body.getBoundingClientRect().width / 3;
visibleNumber.value = parseInt(String(width / 85));
};
const handleSelect = (key: string) => {
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) {
// 没有子路由路径内部打开
const routeMenu = childrenMenus.value.find((item) => item.path === key);
if (routeMenu && routeMenu.query) {
let query = JSON.parse(routeMenu.query);
router.push({ path: key, query: query });
} else {
router.push({ path: key });
}
appStore.toggleSideBarHide(true);
} else {
// 显示左侧联动菜单
activeRoutes(key);
appStore.toggleSideBarHide(false);
}
};
const activeRoutes = (key: string) => {
let routes: RouteRecordRaw[] = [];
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;
}
/* 背景色隐藏 */
.topmenu-container.el-menu--horizontal > .el-menu-item:not(.is-disabled):focus,
.topmenu-container.el-menu--horizontal > .el-menu-item:not(.is-disabled):hover,
.topmenu-container.el-menu--horizontal > .el-submenu .el-submenu__title:hover {
background-color: #ffffff !important;
}
/* 图标右间距 */
.topmenu-container .svg-icon {
margin-right: 4px;
}
</style>

View File

@ -0,0 +1,147 @@
<template>
<div class="el-tree-select">
<el-select
ref="treeSelect"
v-model="valueId"
style="width: 100%"
:filterable="true"
:clearable="true"
:filter-method="selectFilterData"
:placeholder="placeholder"
@clear="clearHandle"
>
<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 lang="ts">
interface ObjMap {
value: string;
label: string;
children: string;
}
interface Props {
objMap: ObjMap;
accordion: boolean;
value: string | number;
options: any[];
placeholder: string;
}
const props = withDefaults(defineProps<Props>(), {
objMap: () => {
return {
value: 'id',
label: 'label',
children: 'children'
};
},
accordion: false,
value: '',
options: () => [],
placeholder: ''
});
const selectTree = ref<ElTreeSelectInstance>();
const emit = defineEmits(['update:value']);
const valueId = computed({
get: () => props.value,
set: (val) => {
emit('update:value', val);
}
});
const valueTitle = ref('');
const defaultExpandedKey = ref<any[]>([]);
const initHandle = () => {
nextTick(() => {
const selectedValue = valueId.value;
if (selectedValue !== null && typeof selectedValue !== 'undefined') {
const node = selectTree.value?.getNode(selectedValue);
if (node) {
valueTitle.value = node.data[props.objMap.label];
selectTree.value?.setCurrentKey(selectedValue); // 设置默认选中
defaultExpandedKey.value = [selectedValue]; // 设置默认展开
}
} else {
clearHandle();
}
});
};
const handleNodeClick = (node: any) => {
valueTitle.value = node[props.objMap.label];
valueId.value = node[props.objMap.value];
defaultExpandedKey.value = [];
selectTree.value?.blur();
selectFilterData('');
};
const selectFilterData = (val: any) => {
selectTree.value?.filter(val);
};
const filterNode = (value: any, data: any) => {
if (!value) return true;
return data[props.objMap['label']].indexOf(value) !== -1;
};
const clearHandle = () => {
valueTitle.value = '';
valueId.value = '';
defaultExpandedKey.value = [];
clearSelected();
};
const 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,306 @@
<template>
<div>
<el-dialog v-model="userDialog.visible.value" :title="userDialog.title.value" width="80%" append-to-body>
<el-row :gutter="20">
<!-- 部门树 -->
<el-col :lg="4" :xs="24" style="">
<el-card shadow="hover">
<el-input v-model="deptName" placeholder="请输入部门名称" prefix-icon="Search" clearable />
<el-tree
ref="deptTreeRef"
class="mt-2"
node-key="id"
:data="deptOptions"
:props="{ label: 'label', children: 'children' }"
:expand-on-click-node="false"
:filter-node-method="filterNode"
highlight-current
default-expand-all
@node-click="handleNodeClick"
/>
</el-card>
</el-col>
<el-col :lg="20" :xs="24">
<transition :enter-active-class="proxy?.animate.searchAnimate.enter" :leave-active-class="proxy?.animate.searchAnimate.leave">
<div v-show="showSearch" class="mb-[10px]">
<el-card shadow="hover">
<el-form ref="queryFormRef" :model="queryParams" :inline="true">
<el-form-item label="用户名称" prop="userName">
<el-input v-model="queryParams.userName" placeholder="请输入用户名称" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="手机号码" prop="phonenumber">
<el-input v-model="queryParams.phonenumber" placeholder="请输入手机号码" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
<el-button icon="Refresh" @click="() => resetQuery()">重置</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</transition>
<el-card shadow="hover">
<template v-if="prop.multiple" #header>
<el-tag v-for="user in selectUserList" :key="user.userId" closable style="margin: 2px" @close="handleCloseTag(user)">
{{ user.nickName }}
</el-tag>
</template>
<vxe-table
ref="tableRef"
height="400px"
border
show-overflow
:data="userList"
:loading="loading"
:row-config="{ keyField: 'userId', isHover: true }"
:checkbox-config="{ reserve: true, trigger: 'row', highlight: true, showHeader: prop.multiple }"
@checkbox-all="handleCheckboxAll"
@checkbox-change="handleCheckboxChange"
>
<vxe-column type="checkbox" width="50" align="center" />
<vxe-column key="userId" title="用户编号" align="center" field="userId" />
<vxe-column key="userName" title="用户名称" align="center" field="userName" />
<vxe-column key="nickName" title="用户昵称" align="center" field="nickName" />
<vxe-column key="deptName" title="部门" align="center" field="deptName" />
<vxe-column key="phonenumber" title="手机号码" align="center" field="phonenumber" width="120" />
<vxe-column key="status" title="状态" align="center">
<template #default="scope">
<dict-tag :options="sys_normal_disable" :value="scope.row.status"></dict-tag>
</template>
</vxe-column>
<vxe-column title="创建时间" align="center" width="160">
<template #default="scope">
<span>{{ scope.row.createTime }}</span>
</template>
</vxe-column>
</vxe-table>
<pagination
v-show="total > 0"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
:total="total"
@pagination="pageList"
/>
</el-card>
</el-col>
</el-row>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" @click="confirm">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import api from '@/api/system/user';
import { UserQuery, UserVO } from '@/api/system/user/types';
import { DeptTreeVO, DeptVO } from '@/api/system/dept/types';
import { VxeTableInstance } from 'vxe-table';
import useDialog from '@/hooks/useDialog';
interface PropType {
modelValue?: UserVO[] | UserVO | undefined;
multiple?: boolean;
data?: string | number | (string | number)[] | undefined;
}
const prop = withDefaults(defineProps<PropType>(), {
multiple: true,
modelValue: undefined,
data: undefined
});
const emit = defineEmits(['update:modelValue', 'confirmCallBack']);
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const { sys_normal_disable } = toRefs<any>(proxy?.useDict('sys_normal_disable'));
const userList = ref<UserVO[]>();
const loading = ref(true);
const showSearch = ref(true);
const total = ref(0);
const dateRange = ref<[DateModelType, DateModelType]>(['', '']);
const deptName = ref('');
const deptOptions = ref<DeptTreeVO[]>([]);
const selectUserList = ref<UserVO[]>([]);
const deptTreeRef = ref<ElTreeInstance>();
const queryFormRef = ref<ElFormInstance>();
const tableRef = ref<VxeTableInstance<UserVO>>();
const userDialog = useDialog({
title: '用户选择'
});
const queryParams = ref<UserQuery>({
pageNum: 1,
pageSize: 10,
userName: '',
phonenumber: '',
status: '',
deptId: '',
roleId: ''
});
const defaultSelectUserIds = computed(() => computedIds(prop.data));
/** 根据名称筛选部门树 */
watchEffect(
() => {
deptTreeRef.value?.filter(deptName.value);
},
{
flush: 'post' // watchEffect会在DOM挂载或者更新之前就会触发此属性控制在DOM元素更新后运行
}
);
const confirm = () => {
emit('update:modelValue', selectUserList.value);
emit('confirmCallBack', selectUserList.value);
userDialog.closeDialog();
};
const computedIds = (data) => {
if (data instanceof Array) {
return data.map(item => String(item));
} else if (typeof data === 'string') {
return data.split(',');
} else if (typeof data === 'number') {
return [data];
} else {
console.warn('<UserSelect> The data type of data should be array or string or number, but I received other');
return [];
}
};
/** 通过条件过滤节点 */
const filterNode = (value: string, data: any) => {
if (!value) return true;
return data.label.indexOf(value) !== -1;
};
/** 查询部门下拉树结构 */
const getTreeSelect = async () => {
const res = await api.deptTreeSelect();
deptOptions.value = res.data;
};
/** 查询用户列表 */
const getList = async () => {
loading.value = true;
const res = await api.listUser(proxy?.addDateRange(queryParams.value, dateRange.value));
loading.value = false;
userList.value = res.rows;
total.value = res.total;
};
const pageList = async () => {
await getList();
const users = userList.value.filter((item) => {
return selectUserList.value.some((user) => user.userId === item.userId);
});
await tableRef.value.setCheckboxRow(users, true);
};
/** 节点单击事件 */
const handleNodeClick = (data: DeptVO) => {
queryParams.value.deptId = data.id;
handleQuery();
};
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.value.pageNum = 1;
getList();
};
/** 重置按钮操作 */
const resetQuery = (refresh = true) => {
dateRange.value = ['', ''];
queryFormRef.value?.resetFields();
queryParams.value.pageNum = 1;
queryParams.value.deptId = undefined;
deptTreeRef.value?.setCurrentKey(undefined);
refresh && handleQuery();
};
const handleCheckboxChange = (checked) => {
if (!prop.multiple && checked.checked) {
tableRef.value.setCheckboxRow(selectUserList.value, false);
selectUserList.value = [];
}
const row = checked.row;
if (checked.checked) {
selectUserList.value.push(row);
} else {
selectUserList.value = selectUserList.value.filter((item) => {
return item.userId !== row.userId;
});
}
};
const handleCheckboxAll = (checked) => {
const rows = userList.value;
if (checked.checked) {
rows.forEach((row) => {
if (!selectUserList.value.some((item) => item.userId === row.userId)) {
selectUserList.value.push(row);
}
});
} else {
selectUserList.value = selectUserList.value.filter((item) => {
return !rows.some((row) => row.userId === item.userId);
});
}
};
const handleCloseTag = (user: UserVO) => {
const userId = user.userId;
// 使用split删除用户
const index = selectUserList.value.findIndex((item) => item.userId === userId);
const rows = selectUserList.value[index];
tableRef.value?.setCheckboxRow(rows, false);
selectUserList.value.splice(index, 1);
};
const initSelectUser = async () => {
if (defaultSelectUserIds.value.length > 0) {
const { data } = await api.optionSelect(defaultSelectUserIds.value);
selectUserList.value = data;
const users = userList.value.filter((item) => {
return defaultSelectUserIds.value.includes(String(item.userId));
});
await nextTick(() => {
tableRef.value.setCheckboxRow(users, true);
});
}
};
const close = () => {
userDialog.closeDialog();
};
watch(
() => userDialog.visible.value,
async (newValue: boolean) => {
if (newValue) {
await getTreeSelect(); // 初始化部门数据
await getList(); // 初始化列表数据
await initSelectUser();
} else {
tableRef.value.clearCheckboxReserve();
tableRef.value.clearCheckboxRow();
resetQuery(false);
selectUserList.value = [];
}
}
);
defineExpose({
open: userDialog.openDialog,
close: userDialog.closeDialog
});
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,136 @@
<template>
<div class="map">
<input type="text" placeholder="请输入地址" v-model="searchValue" />
<button @click="onSearch">搜索</button>
<div id="container" :style="{ 'height': mapProps.height }"></div>
<div id="my-panel" @listElementClick="selectPostion"></div>
<div class="flex justify-end">
<el-button type="primary" @click="submit"> 确定 </el-button>
<el-button @click="emit('setLocation')">取消</el-button>
</div>
</div>
</template>
<script setup>
import { onMounted, onUnmounted } from 'vue';
import AMapLoader from '@amap/amap-jsapi-loader';
const { proxy } = getCurrentInstance();
//props参数
const mapProps = defineProps({
height: {
type: String,
default: '800px'
}
});
const emit = defineEmits(['setLocation']);
const center = ref([116.397428, 39.90923]);
const map = ref(null);
const placeSearch = ref(null);
const geocoder = ref(null);
const searchValue = ref('');
const lnglat = ref([]);
onMounted(() => {
window._AMapSecurityConfig = {
securityJsCode: '3f418182f27c907265f69a708c5fa41c'
};
AMapLoader.load({
key: 'ed8d05ca57affee582e2be654bac5baf', // 申请好的Web端开发者Key首次调用 load 时必填
version: '2.0', // 指定要加载的 JSAPI 的版本,缺省时默认为 1.4.15
plugins: ['AMap.Scale', 'AMap.AutoComplete', 'AMap.PlaceSearch', 'AMap.Geolocation', 'AMap.Geocoder'] //需要使用的的插件列表,如比例尺'AMap.Scale',支持添加多个如:['...','...']
})
.then((AMap) => {
map.value = new AMap.Map('container', {
// 设置地图容器id
viewMode: '3D', // 是否为3D地图模式
zoom: 8, // 初始化地图级别
center: center.value // 初始化地图中心点位置
});
//初始化搜索
placeSearch.value = new AMap.PlaceSearch({
pageSize: 5, //单页显示结果条数
// pageIndex: 1, //页码
// city: '010', //兴趣点城市
// citylimit: true, //是否强制限制在设置的城市内搜索
panel: 'my-panel',
map: map.value, //展现结果的地图实例
autoFitView: true //是否自动调整地图视野使绘制的 Marker 点都处于视口的可见范围
});
// 初始化Geocoder
geocoder.value = new AMap.Geocoder({
radius: 1000 //范围默认500
});
// 定位
const geolocation = new AMap.Geolocation({
enableHighAccuracy: true, //是否使用高精度定位,默认:true
timeout: 10000, //超过10秒后停止定位默认无穷大
maximumAge: 0, //定位结果缓存0毫秒默认0
convert: true, //自动偏移坐标偏移后的坐标为高德坐标默认true
showButton: true, //显示定位按钮默认true
buttonPosition: 'LB', //定位按钮停靠位置,默认:'LB',左下角
buttonOffset: new AMap.Pixel(10, 20), //定位按钮与设置的停靠位置的偏移量默认Pixel(10, 20)
showMarker: true, //定位成功后在定位到的位置显示点标记默认true
showCircle: true, //定位成功后用圆圈表示定位精度范围默认true
panToLocation: true, //定位成功后将定位到的位置作为地图中心点默认true
zoomToAccuracy: true //定位成功后调整地图视野范围使定位位置及精度范围视野内可见默认false
});
map.value.addControl(geolocation);
//定位到当前位置
geolocation.getCurrentPosition((status, result) => {
console.log(status, result);
});
placeSearch.value.on('selectChanged', (e) => {
let { lng, lat } = e.selected.data.location;
lnglat.value = [lng, lat];
});
})
.catch((e) => {
console.log(e);
});
});
const onSearch = () => {
//搜索地址
placeSearch.value.search(searchValue.value, (status, result) => {
if (result.info !== 'OK') return;
let { lng, lat } = result.poiList.pois[0].location;
lnglat.value = [lng, lat];
});
};
const submit = () => {
if (!lnglat.value.length) {
proxy?.$modal.msgWarning('请选择正确地址');
return;
}
geocoder.value.getAddress(lnglat.value, function (status, result) {
if (status === 'complete' && result.info === 'OK') {
// result为对应的地理位置详细信息
const position = {
lng: lnglat.value[0],
lat: lnglat.value[1],
projectSite: result.regeocode.formattedAddress
};
emit('setLocation', position);
}
});
};
onUnmounted(() => {
map.value?.destroy();
});
</script>
<style scoped lang="scss">
#container {
width: 100%;
position: relative;
margin-bottom: 15px;
}
#my-panel {
position: absolute;
top: 103px;
z-index: 1;
left: 10px;
}
</style>

View File

@ -0,0 +1,26 @@
<template>
<div v-loading="loading" :style="'height:' + height">
<iframe :src="url" frameborder="no" style="width: 100%; height: 100%" scrolling="auto" />
</div>
</template>
<script setup lang="ts">
import { propTypes } from '@/utils/propTypes';
const props = defineProps({
src: propTypes.string.isRequired
});
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>

View File

@ -0,0 +1,597 @@
<template>
<div class="flex justify-between" v-loading="treeLoading">
<el-tree-v2
style="width: 340px; overflow: auto"
show-checkbox
:data="jsonData"
:height="500"
@check-change="handleCheckChange"
:props="treeProps"
@node-contextmenu="showMenu"
ref="treeRef"
@node-click="isMenuVisible = false"
>
<template #default="{ node, data }">
<span @dblclick="handlePosition(data, node)">{{ data.name }}</span>
</template>
</el-tree-v2>
<div>
<div class="ol-map" id="olMap"></div>
<div class="h15 mt-2" v-if="!selectLayer.length"></div>
<div class="m-0 c-white text-3 flex w-237.5 mt-2 flex-wrap" v-else>
<p
v-for="(item, index) in selectLayer"
class="pl-xl border-rd pr p-3 w-111 mr-1 bg-#909399 flex items-center cursor-pointer justify-between"
@click="delLayer(index, item.option)"
>
{{ item.location.name + '被选中为' + item.option }}
<el-icon>
<Close />
</el-icon>
</p>
</div>
<!-- <el-form-item label="类型" class="items-center">
<el-radio-group v-model="layerType">
<el-radio :value="1" size="large">光伏板</el-radio>
<el-radio :value="2" size="large">桩点/支架</el-radio>
<el-radio :value="3" size="large">方阵</el-radio>
<el-radio :value="4" size="large">逆变器</el-radio>
<el-radio :value="5" size="large">箱变</el-radio>
</el-radio-group>
</el-form-item> -->
</div>
<div v-if="isMenuVisible" :style="{ left: menuX + 'px', top: menuY + 'px' }" class="fixed bg-white shadow-md rounded-md overflow-hidden">
<ul class="py-1 pl-0">
<li
v-for="(item, index) in layerTypeList"
class="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer"
@click="handleMenuItemClick(item, index + 1)"
>
<i class="fa-solid fa-check mr-2"></i>{{ item }}
</li>
<li class="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer" @click="handleMenuItemClick('名称', null)">
<i class="fa-solid fa-times mr-2"></i>名称
</li>
</ul>
</div>
</div>
<div class="float-right">
<el-button @click="emit('close')">取消</el-button>
<el-button type="primary" @click="addFacilities" :loading="loading">确定</el-button>
</div>
</template>
<script lang="ts" setup>
import Map from 'ol/Map'; // OpenLayers的主要类用于创建和管理地图
import View from 'ol/View'; // OpenLayers的视图类定义地图的视图属性
import { Tile as TileLayer } from 'ol/layer'; // OpenLayers的瓦片图层类
import { XYZ, OSM } from 'ol/source'; // OpenLayers的瓦片数据源包括XYZ格式和OpenStreetMap专用的数据源
import { fromLonLat, toLonLat } from 'ol/proj'; // OpenLayers的投影转换函数用于经纬度坐标和投影坐标之间的转换
import { defaults as defaultInteractions, DragRotateAndZoom } from 'ol/interaction'; // OpenLayers的交互类包括默认的交互集合和特定的旋转缩放交互
import { defaults as defaultControls, defaults, FullScreen, MousePosition, ScaleLine } from 'ol/control'; // OpenLayers的控件类包括默认的控件集合和特定的全屏、鼠标位置、比例尺控件
import Feature from 'ol/Feature'; // OpenLayers的要素类表示地图上的一个对象或实体
import Point from 'ol/geom/Point'; // OpenLayers的点几何类用于表示点状的地理数据
import { Vector as VectorLayer } from 'ol/layer'; // OpenLayers的矢量图层类用于显示矢量数据
import { Vector as VectorSource } from 'ol/source'; // OpenLayers的矢量数据源类用于管理和提供矢量数据
import { Circle, Style, Stroke, Fill, Icon, Text } from 'ol/style'; // OpenLayers的样式类用于定义图层的样式包括圆形样式、基本样式、边框、填充和图标
import LineString from 'ol/geom/LineString'; // OpenLayers的线几何类用于表示线状的地理数据
import Polygon from 'ol/geom/Polygon'; // OpenLayers的多边形几何类用于表示面状的地理数据
import GeoJSON from 'ol/format/GeoJSON';
import * as turf from '@turf/turf';
import { FeatureCollection, Geometry } from 'geojson';
import { MapViewFitter } from '@/utils/setMapCenter';
import { TreeInstance } from 'element-plus';
import { addProjectFacilities, addProjectPilePoint, addProjectSquare, listDXFProject, addInverter, addBoxTransformer } from '@/api/project/project';
import { BatchUploader } from '@/utils/batchUpload';
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const props = defineProps({
projectId: String,
designId: String
});
const treeData = ref<any>([]);
const layerType = ref(null);
const layerTypeList = ref(['光伏板', '桩点/支架', '方阵', '逆变器', '箱变']);
const contextMenu = ref(null);
const selectLayer = ref([]);
const treeRef = ref<TreeInstance>();
const treeProps = {
value: 'name'
};
const loading = ref(false);
const treeLoading = ref(false);
const emit = defineEmits(['handleCheckChange', 'close']);
let map: any = null;
const layerData = reactive<any>({});
const centerPosition = ref(fromLonLat([107.13761560163239, 23.80480003743964]));
const jsonData = computed(() => {
let id = 0;
let arr = [];
treeData.value.forEach((item: any, index: any) => {
arr.push({
name: item.name,
index
});
for (const itm of item.features) {
if (itm.geometry.id) {
break;
}
itm.geometry.id = ++id;
itm.geometry.coordinates = convertStrToNum(itm.geometry.coordinates);
}
});
return arr; // treeData.value;
});
const handlePosition = (data: any, node: any) => {
const fitter = new MapViewFitter(map); // 传入你的 OpenLayers 地图实例
const features = treeData.value[data.index]?.features; //features数组
console.log('🚀 ~ handlePosition ~ features:', features);
if (features?.length) {
const featureCollection: FeatureCollection<Geometry> = {
type: 'FeatureCollection',
features
};
fitter.fit(featureCollection);
}
};
const handleCheckChange = (data: any, bool: boolean) => {
if (isMenuVisible.value) isMenuVisible.value = false;
const features = treeData.value[data.index].features;
if (bool) {
features.forEach((item: any) => {
const fid = item.geometry.id;
// 没创建过就先创建
if (!featureMap[fid]) {
creatPoint(item.geometry.coordinates, item.geometry.type, fid, item.properties.text);
}
// 添加到共享 source 中(避免重复添加)
const feature = featureMap[fid];
if (!sharedSource.hasFeature(feature)) {
sharedSource.addFeature(feature);
}
});
} else {
features.forEach((item: any) => {
const fid = item.geometry.id;
const feature = featureMap[fid];
if (feature && sharedSource.hasFeature(feature)) {
sharedSource.removeFeature(feature);
}
});
}
};
function initOLMap() {
// 创造地图实例
map = new Map({
// 设置地图容器的ID
target: 'olMap',
// 定义地图的图层列表,用于显示特定的地理信息。
layers: [
// 高德地图
// TileLayer表示一个瓦片图层它由一系列瓦片通常是图片组成用于在地图上显示地理数据。
new TileLayer({
// 设置图层的数据源为XYZ类型。XYZ是一个通用的瓦片图层源它允许你通过URL模板来获取瓦片
source: new XYZ({
url: 'https://webrd04.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=7&x={x}&y={y}&z={z}'
})
})
],
// 设置地图的视图参数
// View表示地图的视图它定义了地图的中心点、缩放级别、旋转角度等参数。
view: new View({
// fromLonLat是一个函数用于将经纬度坐标转换为地图的坐标系统。
center: centerPosition.value, //地图中心点
zoom: 15, // 缩放级别
minZoom: 0, // 最小缩放级别
// maxZoom: 18, // 最大缩放级别
constrainResolution: true // 因为存在非整数的缩放级别所以设置该参数为true来让每次缩放结束后自动缩放到距离最近的一个整数级别这个必须要设置当缩放在非整数级别时地图会糊
// projection: 'EPSG:4326' // 投影坐标系默认是3857
}),
// 按住shift进行旋转
// interactions: defaultInteractions().extend([new DragRotateAndZoom()]),
// 控件
// controls: defaults().extend([
// new FullScreen(), // 全屏
// new MousePosition(), // 显示鼠标当前位置的地图坐标
// new ScaleLine() // 显示比例尺
// ])
//加载控件到地图容器中
controls: defaultControls({
zoom: false,
rotate: false,
attribution: false
}).extend([
new FullScreen() // 全屏
])
});
// 事件
// map.on('moveend', (e: any) => {
// // console.log('地图移动', e);
// // 获取当前缩放级别
// var zoomLevel = map.getView().getZoom();
// // console.log('当前缩放级别:', zoomLevel);
// });
// map.on('rendercomplete', () => {
// // console.log('渲染完成');
// });
// map.on('click', (e: any) => {
// var coordinate = e.coordinate;
// // 将投影坐标转换为经纬度坐标
// var lonLatCoordinate = toLonLat(coordinate);
// // 输出转换后的经纬度坐标
// console.log('经纬度坐标:', lonLatCoordinate);
// });
}
//递归字符串数组变成数字
function convertStrToNum(arr) {
if (typeof arr === 'string') {
const num = Number(arr);
return isNaN(num) ? arr : num;
} else if (Array.isArray(arr)) {
return arr.map((item) => convertStrToNum(item));
}
return arr;
}
/**
* 创建图层
* @param {*} pointObj 坐标数组
* @param {*} type 类型
* @param {*} id 唯一id
* @param {*} name 名称
* */
// 共享 source 和图层(全局一次性创建)
const sharedSource = new VectorSource();
const sharedLayer = new VectorLayer({
source: sharedSource,
renderMode: 'image' // 提高渲染性能
} as any);
// id => Feature 映射表
const featureMap: Record<string, Feature> = {};
const creatPoint = (pointObj: Array<any>, type: string, id: string, name?: string) => {
let geometry;
if (type === 'Point') {
geometry = new Point(fromLonLat(pointObj));
} else if (type === 'LineString') {
const coords = pointObj.map((arr: any) => fromLonLat(arr));
// 注意:这里虽然是 LineString 类型,但数据实际表示的是闭合面
geometry = new Polygon([coords]);
} else {
const coords = pointObj.map((arr: any) => arr.map((i: any) => fromLonLat(i)));
geometry = new Polygon(coords);
}
const feature = new Feature({ geometry });
const pointStyle = new Style({
image: new Circle({
radius: 2,
fill: new Fill({ color: 'red' })
}),
text: new Text({
font: '12px Microsoft YaHei',
text: name,
scale: 1,
fill: new Fill({ color: '#7bdd63' })
})
});
const polygonStyle = new Style({
stroke: new Stroke({
color: type === 'LineString' ? 'skyblue' : 'purple',
width: 2
}),
fill: new Fill({ color: 'transparent' })
});
feature.setStyle(type === 'Point' ? pointStyle : polygonStyle);
// 缓存 feature用于后续判断
featureMap[id] = feature;
};
// 控制菜单是否显示
const isMenuVisible = ref(false);
// 菜单的 x 坐标
const menuX = ref(0);
// 菜单的 y 坐标
const menuY = ref(0);
// 显示菜单的方法
const showMenu = (event: MouseEvent, data) => {
console.log('🚀 ~ showMenu ~ data:', data, treeData.value[data.index]);
contextMenu.value = data;
isMenuVisible.value = true;
menuX.value = event.clientX;
menuY.value = event.clientY;
};
// 处理菜单项点击事件的方法
const handleMenuItemClick = (option: string, index: number) => {
isMenuVisible.value = false;
if (selectLayer.value.some((item) => item.location.name === contextMenu.value.name)) {
return proxy?.$modal.msgError('已选择该图层,请勿重复选择');
}
if (selectLayer.value.some((item) => item.option !== '名称' && item.option !== '箱变' && item.option !== '光伏板')) {
if (option !== '名称' && option !== '箱变') return proxy?.$modal.msgError('只能选择一个类型');
}
selectLayer.value.push({ location: contextMenu.value, option });
layerType.value = index ? index : layerType.value; // 设置 layerType 为对应的索引值
emit('handleCheckChange', selectLayer.value);
};
//删除菜单
const delLayer = (index, option) => {
selectLayer.value.splice(index, 1);
if (option != '名称') {
if (selectLayer.value.every((item) => item.option == '名称')) layerType.value = null;
}
emit('handleCheckChange', selectLayer.value);
};
// 点击页面其他区域隐藏菜单
const closeMenuOnClickOutside = (event: MouseEvent) => {
if (isMenuVisible.value) {
const menuElement = document.querySelector('.fixed.bg-white');
if (menuElement && !menuElement.contains(event.target as Node)) {
isMenuVisible.value = false;
}
}
};
// 添加全局点击事件监听器
window.addEventListener('click', closeMenuOnClickOutside);
const getTreeData = async () => {
treeLoading.value = true;
try {
const res = await listDXFProject(props.designId);
treeData.value = res.data.layers;
treeLoading.value = false;
} catch (err) {
treeLoading.value = false;
}
};
// 组件卸载时移除事件监听器
const onUnmounted = () => {
window.removeEventListener('click', closeMenuOnClickOutside);
};
type LayerConfig = {
optionB: string;
apiFunc: (data: any) => Promise<any>;
};
const LAYER_CONFIG: Record<number, LayerConfig> = {
1: { optionB: '光伏板', apiFunc: addProjectFacilities },
3: { optionB: '方阵', apiFunc: addProjectSquare },
4: { optionB: '逆变器', apiFunc: addInverter },
5: { optionB: '箱变', apiFunc: addBoxTransformer }
};
const showError = (msg: string) => proxy?.$modal.msgError(msg);
const showSuccess = (msg: string) => proxy?.$modal.msgSuccess(msg);
const getGeoJsonData = (nameOption = '名称', secondOption: string): { nameGeoJson: any[]; locationGeoJson: any | null } | null => {
const nameLayers = selectLayer.value.filter((item) => item.option === nameOption);
const secondLayer = selectLayer.value.filter((item) => item.option === secondOption);
if (!nameLayers.length || !secondLayer) {
showError(`请选择${nameOption}${secondOption}`);
return null;
}
const nameGeoJson = nameLayers.map((item) => treeData.value[item.location.index]);
const locationGeoJson = secondLayer.map((item) => treeData.value[item.location.index]);
return { nameGeoJson, locationGeoJson };
};
const handleTwoLayerUpload = async (optionB: string, apiFunc: (data: any) => Promise<any>) => {
const geoJson = getGeoJsonData('名称', optionB);
if (!geoJson) return;
if (optionB == '光伏板') return uploadPhotovoltaic(geoJson, apiFunc);
const data = {
projectId: props.projectId,
nameGeoJson: geoJson.nameGeoJson,
locationGeoJson: geoJson.locationGeoJson,
pointGeoJson: null
};
loading.value = true;
await apiFunc(data);
await showSuccess('添加成功');
};
//上传光伏板
const uploadPhotovoltaic = async (geoJson: { nameGeoJson: any[]; locationGeoJson: any }, apiFunc: (data: any) => Promise<any>) => {
// 提取原始 features
let rawNameFeatures = geoJson.nameGeoJson || [];
let rawLocationFeatures = geoJson.locationGeoJson || [];
console.log('🚀 nameGeoJson:', rawNameFeatures);
console.log('🚀 locationGeoJson:', rawLocationFeatures);
// 扁平化处理 FeatureCollection
const nameFeatures = rawNameFeatures.flatMap((fc) => fc.features || []).map((f) => ({ ...f, __source: 'name' }));
const locationFeatures = rawLocationFeatures.flatMap((fc) => fc.features).map((f) => ({ ...f, __source: 'location' }));
// 配对成上传单元
type FeaturePair = { nameFeature: any; locationFeature: any };
const pairedFeatures: FeaturePair[] = nameFeatures.map((name, i) => ({
nameFeature: name,
locationFeature: locationFeatures[i]
}));
// 启动上传
loading.value = true;
const sessionId = new Date().getTime().toString(36) + Math.random().toString(36).substring(2, 15);
const uploader = new BatchUploader({
dataList: pairedFeatures,
chunkSize: 3000, // 一次上传3000对
delay: 200,
uploadFunc: async (chunk, batchNum, totalBatch) => {
const chunkNameFeatures = chunk.map((pair) => pair.nameFeature);
const chunkLocationFeatures = chunk.map((pair) => pair.locationFeature);
console.log(`🚀 上传第 ${batchNum}/${totalBatch} 批,条数:`, chunk.length);
await apiFunc({
projectId: props.projectId,
nameGeoJson: [{ type: 'FeatureCollection', features: chunkNameFeatures }],
locationGeoJson: [{ type: 'FeatureCollection', features: chunkLocationFeatures }],
pointGeoJson: null,
sessionId,
totalBatch,
batchNum
});
},
onComplete: () => {
showSuccess('图层上传完成');
loading.value = false;
}
});
await uploader.start();
};
const handlePointUpload = async () => {
if (selectLayer.value.length > 1) return showError('最多选择一个桩点/支架');
if (selectLayer.value[0].option !== '桩点/支架') return showError('请选择类型为桩点/支架');
const features = treeData.value[selectLayer.value[0].location.index]?.features || [];
if (!features.length) return showError('桩点数据为空');
loading.value = true;
const sessionId = new Date().getTime().toString(36) + Math.random().toString(36).substring(2, 15);
const uploader = new BatchUploader({
dataList: features,
chunkSize: 15000,
delay: 200,
uploadFunc: async (chunk, batchNum, totalBatch) => {
await addProjectPilePoint({
projectId: props.projectId,
locationGeoJson: {
type: 'FeatureCollection',
features: chunk
},
sessionId,
totalBatch,
batchNum
});
},
onComplete: () => {
showSuccess('桩点上传完成');
reset();
loading.value = false;
}
});
await uploader.start();
};
const addFacilities = async () => {
if (!layerType.value) return showError('请选择图层类型');
if (!selectLayer.value.length) return showError('请选择需要上传的图层');
const config = LAYER_CONFIG[layerType.value];
try {
if (layerType.value == 2) {
await handlePointUpload();
} else if (config) {
await handleTwoLayerUpload(config.optionB, config.apiFunc);
} else {
showError('不支持的图层类型');
}
} finally {
reset();
loading.value = false;
}
};
const reset = () => {
selectLayer.value = [];
treeRef.value?.setCheckedKeys([]);
sharedSource.clear(); // 清空共享 source 中的所有要素
layerType.value = null;
};
watch(
() => props.designId,
(newId, oldId) => {
if (newId !== oldId) {
reset();
getTreeData();
}
},
{ immediate: true }
);
onMounted(() => {
// 地图初始化
initOLMap();
map.addLayer(sharedLayer);
// creatPoint(
// [
// [
// [107.13205125908726, 23.806785824010216],
// [107.13218187963494, 23.806867960389773],
// [107.13215698891558, 23.806902336258318],
// [107.13202636835067, 23.8068201998575],
// [107.13205125908726, 23.806785824010216]
// ]
// ],
// 'Polygon',
// '1',
// '测试方阵'
// );
});
</script>
<style scoped lang="scss">
.ol-map {
height: 450px;
width: 950px;
margin: 0 auto;
}
.ol-custome-full-screen {
border-radius: 3px;
font-size: 12px;
padding: 5px 11px;
height: 24px;
background-color: #409eff;
color: #fff;
border: 1px solid #409eff;
&:active {
background-color: #337ecc;
border-color: #66b1ff;
}
&:hover {
background-color: #79bbff;
border-color: #79bbff;
}
}
li {
list-style-type: none;
}
</style>

View File

@ -0,0 +1,57 @@
<template>
<video class="rtc_media_player" width="100%" height="100%" autoplay muted playsinline ref="rtcMediaPlayer"></video>
</template>
<script>
export default {
name: "SrsPlayer",
data() {
return {
webrtc: null, // Instance of SRS SDK
sessionId: null,
simulatorUrl: null,
playerVisible: false,
url: ""
};
},
methods: {
hideInfo() {
document.querySelector(".alert").style.display = "none";
},
async startPlay(url) {
this.url = url;
this.playerVisible = true;
if (this.webrtc) {
this.webrtc.close();
}
this.webrtc = new SrsRtcWhipWhepAsync();
this.$refs.rtcMediaPlayer.srcObject = this.webrtc.stream;
console.log('stream tracks:', this.webrtc.stream.getTracks());
try {
const session = await this.webrtc.play(url);
console.log('after play, stream tracks:', this.webrtc.stream.getTracks());
this.sessionId = session.sessionid;
this.simulatorUrl = `${session.simulator}?drop=1&username=${session.sessionid}`;
} catch (error) {
console.error("Error playing stream:", error);
this.webrtc.close();
this.playerVisible = false;
}
},
},
beforeDestroy() {
// Cleanup the SDK instance on component destroy
if (window[this.url]) {
window[this.url].close();
window[this.url] = null
}
},
};
</script>
<style scoped>
.rtc_media_player {
width: 100%;
height: 100%;
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 922 B

View File

@ -0,0 +1,270 @@
<template>
<el-dialog v-model="isShowDialog" title="变更单详情" draggable width="60vw" :close-on-click-modal="false" :destroy-on-close="true">
<el-card :body-style="{ padding: '20px' }" style="border: none; box-shadow: none">
<div class="dialog-footer">
<div class="btn-item" @click="onLoad">
<img src="./icon/down.png" />
<span>导出</span>
</div>
</div>
<el-form ref="formRef" :model="formData" label-width="100px" id="formContent" style="width: 75%; margin-left: 10%">
<div class="table-content" id="table-content">
<el-row class="mb20" style="display: flex; justify-content: center">
<h2>设计变更申请单总承包</h2>
</el-row>
<el-row class="mb10" style="display: flex; justify-content: space-between">
<div class="head-text">
<span>NO</span>
<span>{{ formData.formNo }}</span>
</div>
<!-- <div class="head-text">
<span>填报时间</span>
<span>{{ formData.createdAt }}</span>
</div> -->
</el-row>
<table style="width: 100%" border="1" cellspacing="1">
<thead>
<tr>
<th width="150">工程名称</th>
<td class="th-bg">{{ formData.projectName }}</td>
<th width="150">提出单位</th>
<td class="th-bg">{{ formData.submitUnit }}</td>
</tr>
</thead>
<tbody>
<tr>
<th width="150">专业</th>
<td class="th-bg">{{ formData.specialty }}</td>
<th width="150">提出日期</th>
<td class="th-bg">{{ parseTime(formData.submitDate, '{y}-{m}-{d}') }}</td>
</tr>
</tbody>
<thead>
<tr>
<th width="150">卷册名称</th>
<td class="th-bg">{{ formData.volumeName }}</td>
<th width="150">附图</th>
<td class="th-bg">
<el-image
v-for="(item, i) of formData.attachmentPicList"
:key="i"
style="width: 100px; height: 100px"
:src="item.url"
fit="cover"
:preview-src-list="[item.url]"
/>
</td>
</tr>
</thead>
<tbody>
<tr>
<th width="150">卷册号</th>
<td colspan="3">{{ formData.volumeNo }}</td>
</tr>
</tbody>
<thead>
<tr>
<th width="150">变更原因</th>
<td colspan="3">{{ formData.changeReason }}</td>
</tr>
</thead>
<tbody>
<tr>
<th width="150">变更内容</th>
<td colspan="3">{{ formData.changeContent }}</td>
</tr>
</tbody>
<tbody>
<tr>
<th width="150">变更费用估算附计算表</th>
<td colspan="3">
<div>
<span
v-for="(item, i) of formData.costEstimationFileList"
:key="i"
style="color: rgb(41 145 255);cursor: pointer"
@click="onOpen(item.url)"
>{{item.originalName}}</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</el-form>
</el-card>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue';
import { listDesignChange, getDesignChange, delDesignChange, addDesignChange, updateDesignChange } from '@/api/design/designChange';
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
import { downLoadOss } from '@/api/system/oss';
// 响应式状态
const isShowDialog = ref(false);
const initFormData: DesignChangeForm = {
id: undefined,
formNo: undefined,
projectName: undefined,
submitUnit: undefined,
specialty: undefined,
submitDate: undefined,
volumeName: undefined,
volumeNo: undefined,
attachmentPic: undefined,
changeReason: [],
changeContent: undefined,
costEstimation: undefined,
costEstimationFile: undefined,
fileId: undefined,
status: undefined,
remark: undefined
};
const data = reactive<PageData<DesignChangeForm, DesignChangeQuery>>({
formData: { ...initFormData }
});
const design_change_reason_type = ref([]);
const { formData } = toRefs(data);
// 打开弹窗
const openDialog = (row?: any, types) => {
resetForm();
design_change_reason_type.value = types;
if (row?.id) {
getInfos(row.id, types);
}
isShowDialog.value = true;
};
// 获取详情数据
const getInfos = async (id: string, types) => {
const res = await getDesignChange(id);
Object.assign(formData.value, res.data);
// 数据处理
if (formData.value.changeReason) {
let arr = formData.value.changeReason.split(',');
var changeReason = types.filter((item) => arr.includes(item.value.toString())).map((item) => item.label);
formData.value.changeReason = changeReason.join('');
}
};
// 重置表单
const resetForm = () => {
Object.keys(formData.value).forEach((key) => {
formData[key] = undefined;
});
};
// 下载文件
const onOpen = (path: string) => {
window.open(path, '_blank');
};
// 导出
const onLoad = async () => {
await downLoadOss({ id: formData.value.id }, '/design/designChange/export/word', '设计变更单.zip');
};
// 关闭弹窗
const closeDialog = () => {
isShowDialog.value = false;
};
// 暴露方法给父组件
defineExpose({
openDialog,
closeDialog
});
</script>
<style scoped lang="scss">
.pic-block {
margin-right: 8px;
}
.file-block {
width: 100%;
border: 1px solid var(--el-border-color);
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
margin-bottom: 5px;
padding: 3px 6px;
}
.ml-2 {
margin-right: 5px;
}
::v-deep .el-icon svg {
height: 100% !important;
width: 100% !important;
}
::v-deep .el-step__icon-inner {
font-size: 14px !important;
font-weight: 700 !important;
}
.dialog-footer {
height: 100px;
display: flex;
flex-direction: column;
justify-content: space-between;
position: absolute;
top: 14%;
right: 10%;
background: #fff;
box-shadow: 0px 0px 10px #ddd;
text-align: center;
padding: 20px 10px;
.btn-item {
display: flex;
flex-direction: column;
justify-content: center;
cursor: pointer;
> span {
padding-top: 5px;
font-size: 14px;
font-weight: 400;
color: rgba(51, 51, 51, 1);
}
}
}
table {
border-collapse: collapse; //合并为一个单一的边框
border-color: rgba(199, 199, 199, 1); //边框颜色按实际自定义即可
}
thead {
tr {
th {
background-color: rgba(247, 247, 247, 1); //设置表格标题背景色
height: 35px; //设置单元格最小高度
text-align: center;
letter-spacing: 5px;
padding: 15px;
}
td {
text-align: left;
height: 35px; //设置单元格最小高度
padding: 15px;
}
.th-bg {
background-color: rgba(247, 247, 247, 1);
}
}
}
tbody {
tr {
td {
text-align: left;
height: 40px; //设置单元格最小高度
padding: 15px;
}
th {
height: 35px; //设置单元格最小高度
text-align: center;
letter-spacing: 5px;
padding: 15px;
}
}
}
.table-content {
box-shadow: 0px 0px 10px #ddd;
padding: 20px;
position: relative;
}
</style>