初始化
This commit is contained in:
39
frontend/src/components/Dialog/index.vue
Normal file
39
frontend/src/components/Dialog/index.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<!-- 全局封装dialog组件 -->
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="50%"
|
||||
:before-close="handleClose"
|
||||
:draggable="true"
|
||||
:destroy-on-close="true"
|
||||
:append-to-body="true"
|
||||
>
|
||||
<div class="container">
|
||||
<slot name="container"></slot>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="dialogVisible = false">
|
||||
确定
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
const dialogVisible = ref<Boolean>(false);
|
||||
const dialogTitle = ref<string>("");
|
||||
const openDialog = (title: string) => {
|
||||
dialogTitle.value = title;
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
defineExpose({
|
||||
openDialog,
|
||||
});
|
||||
onMounted(() => {
|
||||
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped></style>
|
||||
20
frontend/src/components/ErrorMessage/403.vue
Normal file
20
frontend/src/components/ErrorMessage/403.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="not-container">
|
||||
<img src="@/assets/images/403.png" class="not-img" alt="403" />
|
||||
<div class="not-detail">
|
||||
<h2>403</h2>
|
||||
<h4>抱歉,您无权访问该页面~🙅♂️🙅♀️</h4>
|
||||
<el-button type="primary" @click="router.back">返回上一页</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="403">
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use './index.scss';
|
||||
</style>
|
||||
20
frontend/src/components/ErrorMessage/404.vue
Normal file
20
frontend/src/components/ErrorMessage/404.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="not-container">
|
||||
<img src="@/assets/images/404.png" class="not-img" alt="404" />
|
||||
<div class="not-detail">
|
||||
<h2>404</h2>
|
||||
<h4>抱歉,您访问的页面不存在~🤷♂️🤷♀️</h4>
|
||||
<el-button type="primary" @click="router.back">返回上一页</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="404">
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use './index.scss';
|
||||
</style>
|
||||
20
frontend/src/components/ErrorMessage/500.vue
Normal file
20
frontend/src/components/ErrorMessage/500.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="not-container">
|
||||
<img src="@/assets/images/500.png" class="not-img" alt="500" />
|
||||
<div class="not-detail">
|
||||
<h2>500</h2>
|
||||
<h4>抱歉,您的网络不见了~🤦♂️🤦♀️</h4>
|
||||
<el-button type="primary" @click="router.back">返回上一页</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="500">
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use './index.scss';
|
||||
</style>
|
||||
32
frontend/src/components/ErrorMessage/index.scss
Normal file
32
frontend/src/components/ErrorMessage/index.scss
Normal file
@@ -0,0 +1,32 @@
|
||||
.not-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
.not-img {
|
||||
margin-right: 120px;
|
||||
}
|
||||
.not-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
h2,
|
||||
h4 {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
h2 {
|
||||
font-size: 60px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
h4 {
|
||||
margin: 30px 0 20px;
|
||||
font-size: 19px;
|
||||
font-weight: normal;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
.el-button {
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
}
|
||||
67
frontend/src/components/Grid/components/GridItem.vue
Normal file
67
frontend/src/components/Grid/components/GridItem.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div v-show='isShow' :style='style'>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang='ts' name='GridItem'>
|
||||
import { BreakPoint, Responsive } from '../interface/index'
|
||||
|
||||
type Props = {
|
||||
offset?: number;
|
||||
span?: number;
|
||||
suffix?: boolean;
|
||||
xs?: Responsive;
|
||||
sm?: Responsive;
|
||||
md?: Responsive;
|
||||
lg?: Responsive;
|
||||
xl?: Responsive;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
offset: 0,
|
||||
span: 1,
|
||||
suffix: false,
|
||||
xs: undefined,
|
||||
sm: undefined,
|
||||
md: undefined,
|
||||
lg: undefined,
|
||||
xl: undefined,
|
||||
})
|
||||
|
||||
const attrs = useAttrs() as { index: string }
|
||||
const isShow = ref(true)
|
||||
|
||||
// 注入断点
|
||||
const breakPoint = inject<Ref<BreakPoint>>('breakPoint', ref('xl'))
|
||||
const shouldHiddenIndex = inject<Ref<number>>('shouldHiddenIndex', ref(-1))
|
||||
watch(
|
||||
() => [shouldHiddenIndex.value, breakPoint.value],
|
||||
n => {
|
||||
if (!!attrs.index) {
|
||||
isShow.value = !(n[0] !== -1 && parseInt(attrs.index) >= Number(n[0]))
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const gap = inject('gap', 0)
|
||||
const cols = inject('cols', ref(4))
|
||||
const style = computed(() => {
|
||||
let span = props[breakPoint.value]?.span ?? props.span
|
||||
let offset = props[breakPoint.value]?.offset ?? props.offset
|
||||
if (props.suffix) {
|
||||
return {
|
||||
gridColumnStart: cols.value - span - offset + 1,
|
||||
gridColumnEnd: `span ${span + offset}`,
|
||||
marginLeft: offset !== 0 ? `calc(((100% + ${gap}px) / ${span + offset}) * ${offset})` : 'unset',
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
gridColumn: `span ${span + offset > cols.value ? cols.value : span + offset}/span ${
|
||||
span + offset > cols.value ? cols.value : span + offset
|
||||
}`,
|
||||
marginLeft: offset !== 0 ? `calc(((100% + ${gap}px) / ${span + offset}) * ${offset})` : 'unset',
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
153
frontend/src/components/Grid/index.vue
Normal file
153
frontend/src/components/Grid/index.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<div :style='style'>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang='ts' name='Grid'>
|
||||
import type { BreakPoint } from './interface/index'
|
||||
|
||||
type Props = {
|
||||
cols?: number | Record<BreakPoint, number>;
|
||||
collapsed?: boolean;
|
||||
collapsedRows?: number;
|
||||
gap?: [number, number] | number;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
cols: () => ({ xs: 1, sm: 2, md: 2, lg: 3, xl: 4 }),
|
||||
collapsed: false,
|
||||
collapsedRows: 1,
|
||||
gap: 0,
|
||||
})
|
||||
|
||||
onBeforeMount(() => props.collapsed && findIndex())
|
||||
onMounted(() => {
|
||||
resize({ target: { innerWidth: window.innerWidth } } as unknown as UIEvent)
|
||||
window.addEventListener('resize', resize)
|
||||
})
|
||||
onActivated(() => {
|
||||
resize({ target: { innerWidth: window.innerWidth } } as unknown as UIEvent)
|
||||
window.addEventListener('resize', resize)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', resize)
|
||||
})
|
||||
onDeactivated(() => {
|
||||
window.removeEventListener('resize', resize)
|
||||
})
|
||||
|
||||
// 监听屏幕变化
|
||||
const resize = (e: UIEvent) => {
|
||||
let width = (e.target as Window).innerWidth
|
||||
switch (!!width) {
|
||||
case width < 768:
|
||||
breakPoint.value = 'xs'
|
||||
break
|
||||
case width >= 768 && width < 992:
|
||||
breakPoint.value = 'sm'
|
||||
break
|
||||
case width >= 992 && width < 1200:
|
||||
breakPoint.value = 'md'
|
||||
break
|
||||
case width >= 1200 && width < 1920:
|
||||
breakPoint.value = 'lg'
|
||||
break
|
||||
case width >= 1920:
|
||||
breakPoint.value = 'xl'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 注入 gap 间距
|
||||
provide('gap', Array.isArray(props.gap) ? props.gap[0] : props.gap)
|
||||
|
||||
// 注入响应式断点
|
||||
let breakPoint = ref<BreakPoint>('xl')
|
||||
provide('breakPoint', breakPoint)
|
||||
|
||||
// 注入要开始折叠的 index
|
||||
const hiddenIndex = ref(-1)
|
||||
provide('shouldHiddenIndex', hiddenIndex)
|
||||
|
||||
// 注入 cols
|
||||
const gridCols = computed(() => {
|
||||
if (typeof props.cols === 'object') return props.cols[breakPoint.value] ?? props.cols
|
||||
return props.cols
|
||||
})
|
||||
provide('cols', gridCols)
|
||||
|
||||
// 寻找需要开始折叠的字段 index
|
||||
const slots = useSlots().default!()
|
||||
|
||||
const findIndex = () => {
|
||||
let fields: VNodeArrayChildren = []
|
||||
let suffix: VNode | null = null
|
||||
slots.forEach((slot: any) => {
|
||||
// suffix
|
||||
if (typeof slot.type === 'object' && slot.type.name === 'GridItem' && slot.props?.suffix !== undefined) suffix = slot
|
||||
// slot children
|
||||
if (typeof slot.type === 'symbol' && Array.isArray(slot.children)) fields.push(...slot.children)
|
||||
})
|
||||
|
||||
// 计算 suffix 所占用的列
|
||||
let suffixCols = 0
|
||||
if (suffix) {
|
||||
suffixCols =
|
||||
((suffix as VNode).props![breakPoint.value]?.span ?? (suffix as VNode).props?.span ?? 1) +
|
||||
((suffix as VNode).props![breakPoint.value]?.offset ?? (suffix as VNode).props?.offset ?? 0)
|
||||
}
|
||||
try {
|
||||
let find = false
|
||||
fields.reduce((prev = 0, current, index) => {
|
||||
prev +=
|
||||
((current as VNode)!.props![breakPoint.value]?.span ?? (current as VNode)!.props?.span ?? 1) +
|
||||
((current as VNode)!.props![breakPoint.value]?.offset ?? (current as VNode)!.props?.offset ?? 0)
|
||||
if (Number(prev) >= props.collapsedRows * gridCols.value - suffixCols) {
|
||||
hiddenIndex.value = index
|
||||
find = true
|
||||
throw 'find it'
|
||||
}
|
||||
return prev
|
||||
}, 0)
|
||||
if (!find) hiddenIndex.value = -1
|
||||
} catch (e) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// 断点变化时执行 findIndex
|
||||
watch(
|
||||
() => breakPoint.value,
|
||||
() => {
|
||||
if (props.collapsed) findIndex()
|
||||
},
|
||||
)
|
||||
|
||||
// 监听 collapsed
|
||||
watch(
|
||||
() => props.collapsed,
|
||||
value => {
|
||||
if (value) return findIndex()
|
||||
hiddenIndex.value = -1
|
||||
},
|
||||
)
|
||||
|
||||
// 设置间距
|
||||
const gridGap = computed(() => {
|
||||
if (typeof props.gap === 'number') return `${props.gap}px`
|
||||
if (Array.isArray(props.gap)) return `${props.gap[1]}px ${props.gap[0]}px`
|
||||
return 'unset'
|
||||
})
|
||||
|
||||
// 设置 style
|
||||
const style = computed(() => {
|
||||
return {
|
||||
display: 'grid',
|
||||
gridGap: gridGap.value,
|
||||
gridTemplateColumns: `repeat(${gridCols.value}, minmax(0, 1fr))`,
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({ breakPoint })
|
||||
</script>
|
||||
6
frontend/src/components/Grid/interface/index.ts
Normal file
6
frontend/src/components/Grid/interface/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type BreakPoint = "xs" | "sm" | "md" | "lg" | "xl";
|
||||
|
||||
export type Responsive = {
|
||||
span?: number;
|
||||
offset?: number;
|
||||
};
|
||||
363
frontend/src/components/IpAddress/index.vue
Normal file
363
frontend/src/components/IpAddress/index.vue
Normal file
@@ -0,0 +1,363 @@
|
||||
<template>
|
||||
<div :class="{ 'disabled': disabled }">
|
||||
<ul class="ipAdress">
|
||||
<li v-for="(item, index) in ipAddress" :key="index">
|
||||
<input :ref="el => getInputRef(el, index)" v-model="item.value" type="text" class="ipInputClass"
|
||||
:disabled="disabled" @input="checkIpVal(item)" @keyup="$event => turnIpPosition(item, index, $event)"
|
||||
@blur="handleBlur" @mouseup="handleMouseUp(index)" />
|
||||
<div></div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup name="routePage">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
// 接收来自上层的数据
|
||||
const props = defineProps(['value', 'disabled'])
|
||||
|
||||
// 更新数据
|
||||
const $emits = defineEmits(['update:value', 'blur'])
|
||||
|
||||
// 存储四个ref
|
||||
const ipInputRefs = ref<HTMLInputElement[]>([]);
|
||||
// 存储左右标识位
|
||||
let markFlag = ref([
|
||||
{
|
||||
left: false,
|
||||
right: false
|
||||
},
|
||||
{
|
||||
left: false,
|
||||
right: false
|
||||
},
|
||||
{
|
||||
left: false,
|
||||
right: false
|
||||
},
|
||||
{
|
||||
left: false,
|
||||
right: false
|
||||
}
|
||||
])
|
||||
|
||||
// 更新标识
|
||||
let flag = ref(false)
|
||||
|
||||
// 鼠标点击
|
||||
const handleMouseUp = (index: any) => {
|
||||
let input = ipInputRefs.value[index]
|
||||
// 全为false
|
||||
markFlag.value.forEach(item => {
|
||||
item.left = false
|
||||
item.right = false
|
||||
})
|
||||
// 证明在开始阶段
|
||||
if (input.selectionStart == 0) {
|
||||
|
||||
markFlag.value[index].left = true
|
||||
} else {
|
||||
markFlag.value[index].left = false
|
||||
}
|
||||
// 证明在结束
|
||||
if (input.selectionStart == (input.value || '').length) {
|
||||
markFlag.value[index].right = true
|
||||
} else {
|
||||
markFlag.value[index].right = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取四个input refs
|
||||
const getInputRef = (el: any, index: number) => {
|
||||
if (el) {
|
||||
ipInputRefs.value[index] = el;
|
||||
}
|
||||
};
|
||||
// 声明IP存储类型
|
||||
interface IpType {
|
||||
value: string
|
||||
}
|
||||
// 定义要显示的四个ip
|
||||
let ipAddress = ref<IpType[]>([
|
||||
{
|
||||
value: "",
|
||||
},
|
||||
{
|
||||
value: "",
|
||||
},
|
||||
{
|
||||
value: "",
|
||||
},
|
||||
{
|
||||
value: "",
|
||||
},
|
||||
])
|
||||
// 初始化显示数据
|
||||
const initShowData = () => {
|
||||
// 判断不合理行为
|
||||
if (props.value === '') {
|
||||
ipAddress.value.forEach(item => {
|
||||
item.value = ''
|
||||
})
|
||||
} else {
|
||||
let ipList = props.value.split('.')
|
||||
ipAddress.value.forEach((item: IpType, index: number) => {
|
||||
item.value = ipList[index]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 检查ip输入
|
||||
const checkIpVal = (item: any) => {
|
||||
let val = item.value;
|
||||
|
||||
// 处理非数字
|
||||
val = val.toString().replace(/[^0-9]/g, "");
|
||||
val = parseInt(val, 10);
|
||||
if (isNaN(val)) {
|
||||
val = "";
|
||||
} else {
|
||||
val = val < 0 ? 0 : val;
|
||||
if (val > 255) {
|
||||
// 判断val是几位数
|
||||
let num = (val + '').length
|
||||
if (num > 3) {
|
||||
val = parseInt((val + '').substring(0, 3))
|
||||
} else {
|
||||
val = 255
|
||||
}
|
||||
}
|
||||
}
|
||||
item.value = val;
|
||||
|
||||
}
|
||||
|
||||
// 判断光标左右移动位置
|
||||
const turnIpPosition = (item: IpType, index: number, event: any) => {
|
||||
let e = event || window.event;
|
||||
if (e.keyCode === 37) {
|
||||
// 左箭头向左跳转,左一不做任何措施
|
||||
if (index == 0) {
|
||||
return
|
||||
}
|
||||
if (e.currentTarget.selectionStart === 0) {
|
||||
if (markFlag.value[index].left) {
|
||||
handleFocus(index - 1, 'toLeft')
|
||||
markFlag.value[index].left = false
|
||||
markFlag.value[index].right = false
|
||||
} else {
|
||||
|
||||
markFlag.value[index].left = true
|
||||
}
|
||||
} else {
|
||||
markFlag.value[index].right = false
|
||||
markFlag.value[index].left = false
|
||||
}
|
||||
} else if (e.keyCode == 39) {
|
||||
// 右箭头向右跳转,右一不做任何措施
|
||||
markFlag.value[index].left = false
|
||||
let start = e.currentTarget.selectionStart
|
||||
if (index != 3 && start === item.value.toString().length) {
|
||||
if (markFlag.value[index].right) {
|
||||
handleFocus(index + 1, 'toRight')
|
||||
markFlag.value[index].left = false
|
||||
markFlag.value[index].right = false
|
||||
} else {
|
||||
markFlag.value[index].right = true
|
||||
}
|
||||
} else {
|
||||
markFlag.value[index].right = false
|
||||
}
|
||||
} else if (e.keyCode === 8) {
|
||||
|
||||
// 删除键把当前数据删除完毕后会跳转到前一个input,左一不做任何处理
|
||||
if (index !== 0 && e.currentTarget.selectionStart === 0) {
|
||||
if (markFlag.value[index].left) {
|
||||
ipInputRefs.value[index - 1].focus();
|
||||
markFlag.value[index].left = false
|
||||
} else {
|
||||
|
||||
markFlag.value[index].left = true
|
||||
}
|
||||
}
|
||||
} else if (e.keyCode === 13 || e.keyCode === 32) {
|
||||
|
||||
// 回车键、空格键、冒号均向右跳转,右一不做任何措施
|
||||
if (index !== 3) {
|
||||
ipInputRefs.value[index + 1].focus();
|
||||
}
|
||||
}
|
||||
else if (e.keyCode === 110 || e.keyCode === 190) {
|
||||
// 点 . 向右跳转,右一不做任何措施
|
||||
|
||||
if (item.value == '') {
|
||||
return
|
||||
}
|
||||
if (index !== 3) {
|
||||
ipInputRefs.value[index + 1].select();
|
||||
}
|
||||
}
|
||||
else if (item.value.toString().length === 3) {
|
||||
|
||||
// 满3位,光标自动向下一个文本框.
|
||||
if (index !== 3) {
|
||||
//ipInputRefs.value[index + 1].setSelectionRange(0, 0)
|
||||
ipInputRefs.value[index + 1].focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理聚焦
|
||||
const handleFocus = (index: number, direction: string) => {
|
||||
// 设置当前位置为选中状态 toRight:从左边来的
|
||||
let input = ipInputRefs.value[index]
|
||||
input.focus()
|
||||
let value = input.value
|
||||
// null 左右全部设置为true,可以直接跳转
|
||||
if ((value || '').length == 0) {
|
||||
markFlag.value[index].right = true
|
||||
markFlag.value[index].left = true
|
||||
} else {
|
||||
if (direction == 'toRight') {
|
||||
// 可以直接跳回
|
||||
|
||||
markFlag.value[index].left = true
|
||||
// 设置光标为左边第一个
|
||||
ipInputRefs.value[index].setSelectionRange(0, 0)
|
||||
// 设置上一个的右边标识为false
|
||||
markFlag.value[index - 1] && (markFlag.value[index - 1].right = false)
|
||||
} else {
|
||||
// 直接跳回
|
||||
markFlag.value[index].right = true
|
||||
// 设置后一个侧边为false
|
||||
markFlag.value[index + 1] && (markFlag.value[index + 1].left = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 格式化补零方法
|
||||
const formatter = (val: string) => {
|
||||
let value = val.toString();
|
||||
if (value.length === 2) {
|
||||
value = "0" + value;
|
||||
} else if (value.length === 1) {
|
||||
value = "00" + value;
|
||||
} else if (value.length === 0) {
|
||||
value = "000";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// 监听数据变化,并初始化显示四个数据
|
||||
watch(() => props.value, () => {
|
||||
|
||||
if(flag.value){
|
||||
|
||||
}else{
|
||||
initShowData()
|
||||
}
|
||||
}, {
|
||||
immediate: true
|
||||
})
|
||||
|
||||
// 监听ipAddress数据变化
|
||||
watch(ipAddress, () => {
|
||||
let str = "";
|
||||
for (const i in ipAddress.value) {
|
||||
str += formatter(ipAddress.value[i].value);
|
||||
}
|
||||
if (str === "000000000000") {
|
||||
str = "";
|
||||
} else {
|
||||
str = ipAddress.value.map(item => {
|
||||
if (item.value !== null) {
|
||||
return item.value + ''
|
||||
} else {
|
||||
return '0'
|
||||
}
|
||||
}).join(".")
|
||||
}
|
||||
$emits('update:value', str)
|
||||
flag.value = true
|
||||
setTimeout(() => {
|
||||
flag.value = false
|
||||
}, 100)
|
||||
}, {
|
||||
deep: true
|
||||
})
|
||||
|
||||
const handleBlur = () => {
|
||||
$emits('blur')
|
||||
}
|
||||
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.disabled {
|
||||
cursor: not-allowed;
|
||||
background-color: #f5f7fa;
|
||||
|
||||
.ipAdress {
|
||||
li {
|
||||
.ipInputClass {
|
||||
color: #c3c4cc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ipAdress {
|
||||
display: flex;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
line-height: 40px;
|
||||
width: 100%;
|
||||
height: 35px;
|
||||
padding-inline-start: 0px;
|
||||
padding-left: 10px;
|
||||
padding-right: 20px;
|
||||
padding-top: 0px;
|
||||
padding-bottom: 0px;;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ipAdress li {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
.ipInputClass {
|
||||
border: none;
|
||||
width: 50px;
|
||||
height: 23px;
|
||||
text-align: center;
|
||||
color: #606266;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ipAdress li div {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
right: 0;
|
||||
border-radius: 50%;
|
||||
background: #b6b8bc;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
/*只需要3个div*/
|
||||
.ipAdress li:last-child div {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/*取消掉默认的input focus状态*/
|
||||
.ipAdress input:focus {
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
45
frontend/src/components/Loading/fullScreen.ts
Normal file
45
frontend/src/components/Loading/fullScreen.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { ElLoading } from "element-plus";
|
||||
|
||||
/* 全局请求 loading */
|
||||
let loadingInstance: ReturnType<typeof ElLoading.service>;
|
||||
|
||||
/**
|
||||
* @description 开启 Loading
|
||||
* */
|
||||
const startLoading = () => {
|
||||
loadingInstance = ElLoading.service({
|
||||
fullscreen: true,
|
||||
lock: true,
|
||||
text: "Loading",
|
||||
background: "rgba(0, 0, 0, 0.7)"
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description 结束 Loading
|
||||
* */
|
||||
const endLoading = () => {
|
||||
loadingInstance.close();
|
||||
};
|
||||
|
||||
/**
|
||||
* @description 显示全屏加载
|
||||
* */
|
||||
let needLoadingRequestCount = 0;
|
||||
export const showFullScreenLoading = () => {
|
||||
if (needLoadingRequestCount === 0) {
|
||||
startLoading();
|
||||
}
|
||||
needLoadingRequestCount++;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description 隐藏全屏加载
|
||||
* */
|
||||
export const tryHideFullScreenLoading = () => {
|
||||
if (needLoadingRequestCount <= 0) return;
|
||||
needLoadingRequestCount--;
|
||||
if (needLoadingRequestCount === 0) {
|
||||
endLoading();
|
||||
}
|
||||
};
|
||||
67
frontend/src/components/Loading/index.scss
Normal file
67
frontend/src/components/Loading/index.scss
Normal file
@@ -0,0 +1,67 @@
|
||||
.loading-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
.loading-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 98px;
|
||||
}
|
||||
}
|
||||
.dot {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 32px;
|
||||
transform: rotate(45deg);
|
||||
animation: ant-rotate 1.2s infinite linear;
|
||||
}
|
||||
.dot i {
|
||||
position: absolute;
|
||||
display: block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background-color: var(--el-color-primary);
|
||||
border-radius: 100%;
|
||||
opacity: 0.3;
|
||||
transform: scale(0.75);
|
||||
transform-origin: 50% 50%;
|
||||
animation: ant-spin-move 1s infinite linear alternate;
|
||||
}
|
||||
.dot i:nth-child(1) {
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
.dot i:nth-child(2) {
|
||||
top: 0;
|
||||
right: 0;
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
.dot i:nth-child(3) {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
animation-delay: 0.8s;
|
||||
}
|
||||
.dot i:nth-child(4) {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
animation-delay: 1.2s;
|
||||
}
|
||||
|
||||
@keyframes ant-rotate {
|
||||
to {
|
||||
transform: rotate(405deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ant-spin-move {
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
13
frontend/src/components/Loading/index.vue
Normal file
13
frontend/src/components/Loading/index.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div class="loading-box">
|
||||
<div class="loading-wrap">
|
||||
<span class="dot dot-spin"><i></i><i></i><i></i><i></i></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="Loading"></script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "./index.scss";;
|
||||
</style>
|
||||
45
frontend/src/components/ProTable/components/ColSetting.vue
Normal file
45
frontend/src/components/ProTable/components/ColSetting.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<!-- 列设置 -->
|
||||
<el-drawer v-model="drawerVisible" title="列设置" size="450px">
|
||||
<div class="table-main">
|
||||
<el-table :data="colSetting" :border="true" row-key="prop" default-expand-all :tree-props="{ children: '_children' }">
|
||||
<el-table-column prop="label" align="center" label="列名" />
|
||||
<el-table-column v-slot="scope" prop="isShow" align="center" label="显示">
|
||||
<el-switch v-model="scope.row.isShow"></el-switch>
|
||||
</el-table-column>
|
||||
<el-table-column v-slot="scope" prop="sortable" align="center" label="排序">
|
||||
<el-switch v-model="scope.row.sortable"></el-switch>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<div class="table-empty">
|
||||
<img src="@/assets/images/notData.png" alt="notData" />
|
||||
<div>暂无可配置列</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="ColSetting">
|
||||
import { ref } from "vue";
|
||||
import { ColumnProps } from "@/components/ProTable/interface";
|
||||
|
||||
defineProps<{ colSetting: ColumnProps[] }>();
|
||||
|
||||
const drawerVisible = ref<boolean>(false);
|
||||
|
||||
const openColSetting = () => {
|
||||
drawerVisible.value = true;
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
openColSetting
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.cursor-move {
|
||||
cursor: move;
|
||||
}
|
||||
</style>
|
||||
39
frontend/src/components/ProTable/components/Pagination.vue
Normal file
39
frontend/src/components/ProTable/components/Pagination.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<!-- 分页组件 -->
|
||||
<el-pagination
|
||||
:background="true"
|
||||
:current-page="pageable.current"
|
||||
:page-size="pageable.size"
|
||||
:page-sizes="[10, 25, 50, 100]"
|
||||
:total="pageable.total"
|
||||
:size="globalStore?.assemblySize ?? 'default'"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
></el-pagination>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="Pagination">
|
||||
import { useGlobalStore } from "@/stores/modules/global";
|
||||
const globalStore = useGlobalStore();
|
||||
|
||||
interface Pageable {
|
||||
current: number;
|
||||
size: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface ResPageable {
|
||||
current: number;
|
||||
size: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface PaginationProps {
|
||||
pageable: ResPageable;
|
||||
handleSizeChange: (size: number) => void;
|
||||
handleCurrentChange: (currentPage: number) => void;
|
||||
}
|
||||
|
||||
defineProps<PaginationProps>();
|
||||
</script>
|
||||
57
frontend/src/components/ProTable/components/TableColumn.vue
Normal file
57
frontend/src/components/ProTable/components/TableColumn.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<RenderTableColumn v-bind='column' />
|
||||
</template>
|
||||
|
||||
<script setup lang='tsx' name='TableColumn'>
|
||||
import { ColumnProps, RenderScope, HeaderRenderScope } from '@/components/ProTable/interface'
|
||||
import { filterEnum, formatValue, handleProp, handleRowAccordingToProp } from '@/utils'
|
||||
|
||||
defineProps<{ column: ColumnProps }>()
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const enumMap = inject('enumMap', ref(new Map()))
|
||||
|
||||
// 渲染表格数据
|
||||
const renderCellData = (item: ColumnProps, scope: RenderScope<any>) => {
|
||||
return enumMap.value.get(item.prop) && item.isFilterEnum
|
||||
? filterEnum(handleRowAccordingToProp(scope.row, item.prop!), enumMap.value.get(item.prop)!, item.fieldNames)
|
||||
: formatValue(handleRowAccordingToProp(scope.row, item.prop!))
|
||||
}
|
||||
|
||||
// 获取 tag 类型
|
||||
const getTagType = (item: ColumnProps, scope: RenderScope<any>) => {
|
||||
return (
|
||||
filterEnum(handleRowAccordingToProp(scope.row, item.prop!), enumMap.value.get(item.prop), item.fieldNames, 'tag') || 'primary'
|
||||
)
|
||||
}
|
||||
|
||||
const RenderTableColumn = (item: ColumnProps) => {
|
||||
return (
|
||||
<>
|
||||
{item.isShow && (
|
||||
<el-table-column
|
||||
{...item}
|
||||
align={item.align ?? 'center'}
|
||||
showOverflowTooltip={item.showOverflowTooltip ?? item.prop !== 'operation'}
|
||||
>
|
||||
{{
|
||||
default: (scope: RenderScope<any>) => {
|
||||
if (item._children) return item._children.map(child => RenderTableColumn(child))
|
||||
if (item.render) return item.render(scope)
|
||||
if (item.prop && slots[handleProp(item.prop)]) return slots[handleProp(item.prop)]!(scope)
|
||||
if (item.tag) return <el-tag type={getTagType(item, scope)}>{renderCellData(item, scope)}</el-tag>
|
||||
return renderCellData(item, scope)
|
||||
},
|
||||
header: (scope: HeaderRenderScope<any>) => {
|
||||
if (item.headerRender) return item.headerRender(scope)
|
||||
if (item.prop && slots[`${handleProp(item.prop)}Header`]) return slots[`${handleProp(item.prop)}Header`]!(scope)
|
||||
return item.label
|
||||
},
|
||||
}}
|
||||
</el-table-column>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</script>
|
||||
336
frontend/src/components/ProTable/index.vue
Normal file
336
frontend/src/components/ProTable/index.vue
Normal file
@@ -0,0 +1,336 @@
|
||||
<template>
|
||||
<!-- 查询表单 -->
|
||||
<SearchForm
|
||||
v-show='isShowSearch'
|
||||
:search='_search'
|
||||
:reset='_reset'
|
||||
:columns='searchColumns'
|
||||
:search-param='searchParam'
|
||||
:search-col='searchCol'
|
||||
/>
|
||||
<!-- 表格主体 -->
|
||||
<div class='table-main' :class='{ card: showCard }' >
|
||||
<!-- 表格头部 操作按钮 -->
|
||||
<div class='table-header'>
|
||||
<div class='header-button-lf'>
|
||||
<slot name='tableHeader' :selected-list='selectedList' :selected-list-ids='selectedListIds'
|
||||
:is-selected='isSelected' />
|
||||
</div>
|
||||
<div v-if='toolButton' class='header-button-ri'>
|
||||
<slot name='toolButton'>
|
||||
<el-button v-if="showToolButton('refresh')" :icon='Refresh' circle @click='getTableList' />
|
||||
<el-button v-if="showToolButton('setting') && columns.length" :icon='Operation' circle
|
||||
@click='openColSetting' />
|
||||
<el-button
|
||||
v-if="showToolButton('search') && searchColumns?.length"
|
||||
:icon='Search'
|
||||
circle
|
||||
@click='isShowSearch = !isShowSearch'
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 表格主体 -->
|
||||
<el-table
|
||||
ref='tableRef'
|
||||
v-bind='$attrs'
|
||||
:id='uuid'
|
||||
:data='processTableData'
|
||||
:border='border'
|
||||
:row-key='rowKey'
|
||||
@selection-change='selectionChange'
|
||||
>
|
||||
<!-- 默认插槽 -->
|
||||
<slot />
|
||||
|
||||
<template v-for='item in tableColumns' :key='item'>
|
||||
<!-- selection || radio || index || expand || sort -->
|
||||
<el-table-column
|
||||
v-if='item.type && columnTypes.includes(item.type) && item.isShow'
|
||||
v-bind='item'
|
||||
:align="item.align ?? 'center'"
|
||||
:reserve-selection="item.type == 'selection'"
|
||||
>
|
||||
<template #default='scope'>
|
||||
<!-- expand -->
|
||||
<template v-if="item.type == 'expand'">
|
||||
<component :is='item.render' v-bind='scope' v-if='item.render' />
|
||||
<slot v-else :name='item.type' v-bind='scope' />
|
||||
</template>
|
||||
<!-- radio -->
|
||||
<el-radio v-if="item.type == 'radio'" v-model='radio' :label='scope.row[rowKey]'>
|
||||
<i></i>
|
||||
</el-radio>
|
||||
<!-- sort -->
|
||||
<el-tag v-if="item.type == 'sort'" class='move'>
|
||||
<el-icon>
|
||||
<DCaret />
|
||||
</el-icon>
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- other -->
|
||||
<TableColumn v-else :column='item'>
|
||||
<template v-for='slot in Object.keys($slots)' #[slot]='scope'>
|
||||
<slot :name='slot' v-bind='scope' />
|
||||
</template>
|
||||
</TableColumn>
|
||||
</template>
|
||||
<!-- 插入表格最后一行之后的插槽 -->
|
||||
<template #append>
|
||||
<slot name='append' />
|
||||
</template>
|
||||
<!-- 无数据 -->
|
||||
<template #empty>
|
||||
<div class='table-empty'>
|
||||
<slot name='empty'>
|
||||
<img src='@/assets/images/notData.png' alt='notData' />
|
||||
<div>暂无数据</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
</el-table>
|
||||
<!-- 分页组件 -->
|
||||
<slot name='pagination'>
|
||||
<Pagination
|
||||
v-if='pagination'
|
||||
:pageable='resPageable'
|
||||
:handle-size-change='handleSizeChange'
|
||||
:handle-current-change='handleCurrentChange'
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
<!-- 列设置 -->
|
||||
<ColSetting v-if='toolButton' ref='colRef' v-model:col-setting='colSetting' />
|
||||
</template>
|
||||
|
||||
<script setup lang='ts' name='ProTable'>
|
||||
import { ElTable } from 'element-plus'
|
||||
import { useTable } from '@/hooks/useTable'
|
||||
import { useSelection } from '@/hooks/useSelection'
|
||||
import { BreakPoint } from '@/components/Grid/interface'
|
||||
import { ColumnProps, TypeProps } from '@/components/ProTable/interface'
|
||||
import { Refresh, Operation, Search } from '@element-plus/icons-vue'
|
||||
import { generateUUID, handleProp } from '@/utils'
|
||||
import SearchForm from '@/components/SearchForm/index.vue'
|
||||
import Pagination from './components/Pagination.vue'
|
||||
import ColSetting from './components/ColSetting.vue'
|
||||
import TableColumn from './components/TableColumn.vue'
|
||||
import Sortable from 'sortablejs'
|
||||
|
||||
export interface ProTableProps {
|
||||
|
||||
columns: ColumnProps[]; // 列配置项 ==> 必传
|
||||
data?: any[]; // 静态 table data 数据,若存在则不会使用 requestApi 返回的 data ==> 非必传
|
||||
requestApi?: (params: any) => Promise<any>; // 请求表格数据的 api ==> 非必传
|
||||
requestAuto?: boolean; // 是否自动执行请求 api ==> 非必传(默认为true)
|
||||
requestError?: (params: any) => void; // 表格 api 请求错误监听 ==> 非必传
|
||||
dataCallback?: (data: any) => any; // 返回数据的回调函数,可以对数据进行处理 ==> 非必传
|
||||
title?: string; // 表格标题 ==> 非必传
|
||||
showCard?: boolean; // 下个是否需要卡片
|
||||
pagination?: boolean; // 是否需要分页组件 ==> 非必传(默认为true)
|
||||
initParam?: any; // 初始化请求参数 ==> 非必传(默认为{})
|
||||
border?: boolean; // 是否带有纵向边框 ==> 非必传(默认为true)
|
||||
toolButton?: ('refresh' | 'setting' | 'search')[] | boolean; // 是否显示表格功能按钮 ==> 非必传(默认为true)
|
||||
rowKey?: string; // 行数据的 Key,用来优化 Table 的渲染,当表格数据多选时,所指定的 id ==> 非必传(默认为 id)
|
||||
searchCol?: number | Record<BreakPoint, number>; // 表格搜索项 每列占比配置 ==> 非必传 { xs: 1, sm: 2, md: 2, lg: 3, xl: 4 }
|
||||
}
|
||||
|
||||
// 接受父组件参数,配置默认值
|
||||
const props = withDefaults(defineProps<ProTableProps>(), {
|
||||
columns: () => [],
|
||||
requestAuto: true,
|
||||
pagination: true,
|
||||
initParam: {},
|
||||
border: true,
|
||||
showCard: true,
|
||||
toolButton: true,
|
||||
rowKey: 'id',
|
||||
searchCol: () => ({ xs: 1, sm: 2, md: 2, lg: 3, xl: 4 }),
|
||||
})
|
||||
|
||||
// table 实例
|
||||
const tableRef = ref<InstanceType<typeof ElTable>>()
|
||||
|
||||
// 生成组件唯一id
|
||||
const uuid = ref('id-' + generateUUID())
|
||||
|
||||
// column 列类型
|
||||
const columnTypes: TypeProps[] = ['selection', 'radio', 'index', 'expand', 'sort']
|
||||
|
||||
// 是否显示搜索模块
|
||||
const isShowSearch = ref(true)
|
||||
|
||||
// 控制 ToolButton 显示
|
||||
const showToolButton = (key: 'refresh' | 'setting' | 'search') => {
|
||||
return Array.isArray(props.toolButton) ? props.toolButton.includes(key) : props.toolButton
|
||||
}
|
||||
|
||||
// 单选值
|
||||
const radio = ref('')
|
||||
|
||||
// 表格多选 Hooks
|
||||
const { selectionChange, selectedList, selectedListIds, isSelected } = useSelection(props.rowKey)
|
||||
|
||||
// 表格操作 Hooks
|
||||
const {
|
||||
tableData,
|
||||
pageable,
|
||||
resPageable,
|
||||
searchParam,
|
||||
searchInitParam,
|
||||
getTableList,
|
||||
search,
|
||||
reset,
|
||||
handleSizeChange,
|
||||
handleCurrentChange,
|
||||
} =
|
||||
useTable(props.requestApi, props.initParam, props.pagination, props.dataCallback, props.requestError)
|
||||
|
||||
// 清空选中数据列表
|
||||
const clearSelection = () => tableRef.value!.clearSelection()
|
||||
|
||||
// 初始化表格数据 && 拖拽排序
|
||||
onMounted(() => {
|
||||
dragSort()
|
||||
props.requestAuto && getTableList()
|
||||
props.data && (resPageable.value.total = props.data.length)
|
||||
})
|
||||
|
||||
// 处理表格数据
|
||||
const processTableData = computed(() => {
|
||||
if (!props.data) return tableData.value
|
||||
if (!props.pagination) return props.data
|
||||
return props.data.slice(
|
||||
(resPageable.value.current - 1) * resPageable.value.size,
|
||||
resPageable.value.size * resPageable.value.current,
|
||||
)
|
||||
})
|
||||
|
||||
// 监听页面 initParam 改化,重新获取表格数据
|
||||
watch(() => props.initParam, getTableList, { deep: true })
|
||||
|
||||
// 接收 columns 并设置为响应式
|
||||
const tableColumns = reactive<ColumnProps[]>(props.columns)
|
||||
|
||||
// 扁平化 columns
|
||||
const flatColumns = computed(() => flatColumnsFunc(tableColumns))
|
||||
|
||||
// 定义 enumMap 存储 enum 值(避免异步请求无法格式化单元格内容 || 无法填充搜索下拉选择)
|
||||
const enumMap = ref(new Map<string, { [key: string]: any }[]>())
|
||||
const setEnumMap = async ({ prop, enum: enumValue }: ColumnProps) => {
|
||||
if (!enumValue) return
|
||||
|
||||
// 如果当前 enumMap 存在相同的值 return
|
||||
if (enumMap.value.has(prop!) && (typeof enumValue === 'function' || enumMap.value.get(prop!) === enumValue)) return
|
||||
|
||||
// 当前 enum 为静态数据,则直接存储到 enumMap
|
||||
if (typeof enumValue !== 'function') return enumMap.value.set(prop!, unref(enumValue!))
|
||||
|
||||
// 为了防止接口执行慢,而存储慢,导致重复请求,所以预先存储为[],接口返回后再二次存储
|
||||
enumMap.value.set(prop!, [])
|
||||
|
||||
// 当前 enum 为后台数据需要请求数据,则调用该请求接口,并存储到 enumMap
|
||||
const { data } = await enumValue()
|
||||
enumMap.value.set(prop!, data)
|
||||
}
|
||||
|
||||
// 注入 enumMap
|
||||
provide('enumMap', enumMap)
|
||||
|
||||
// 扁平化 columns 的方法
|
||||
const flatColumnsFunc = (columns: ColumnProps[], flatArr: ColumnProps[] = []) => {
|
||||
columns.forEach(async col => {
|
||||
if (col._children?.length) flatArr.push(...flatColumnsFunc(col._children))
|
||||
flatArr.push(col)
|
||||
|
||||
// column 添加默认 isShow && isSetting && isFilterEnum 属性值
|
||||
col.isShow = col.isShow ?? true
|
||||
col.isSetting = col.isSetting ?? true
|
||||
col.isFilterEnum = col.isFilterEnum ?? true
|
||||
|
||||
// 设置 enumMap
|
||||
await setEnumMap(col)
|
||||
})
|
||||
return flatArr.filter(item => !item._children?.length)
|
||||
}
|
||||
|
||||
// 过滤需要搜索的配置项 && 排序
|
||||
const searchColumns = computed(() => {
|
||||
return flatColumns.value
|
||||
?.filter(item => item.search?.el || item.search?.render)
|
||||
.sort((a, b) => a.search!.order! - b.search!.order!)
|
||||
})
|
||||
|
||||
// 设置 搜索表单默认排序 && 搜索表单项的默认值
|
||||
searchColumns.value?.forEach((column, index) => {
|
||||
column.search!.order = column.search?.order ?? index + 2
|
||||
const key = column.search?.key ?? handleProp(column.prop!)
|
||||
const defaultValue = column.search?.defaultValue
|
||||
if (defaultValue !== undefined && defaultValue !== null) {
|
||||
searchParam.value[key] = defaultValue
|
||||
searchInitParam.value[key] = defaultValue
|
||||
}
|
||||
})
|
||||
|
||||
// 列设置 ==> 需要过滤掉不需要设置的列
|
||||
const colRef = ref()
|
||||
const colSetting = tableColumns!.filter(item => {
|
||||
const { type, prop, isSetting } = item
|
||||
return !columnTypes.includes(type!) && prop !== 'operation' && isSetting
|
||||
})
|
||||
const openColSetting = () => colRef.value.openColSetting()
|
||||
|
||||
// 定义 emit 事件
|
||||
const emit = defineEmits<{
|
||||
search: [];
|
||||
reset: [];
|
||||
dragSort: [{ newIndex?: number; oldIndex?: number }];
|
||||
}>()
|
||||
|
||||
const _search = () => {
|
||||
search()
|
||||
emit('search')
|
||||
}
|
||||
|
||||
const _reset = () => {
|
||||
reset()
|
||||
emit('reset')
|
||||
}
|
||||
|
||||
// 表格拖拽排序
|
||||
const dragSort = () => {
|
||||
const tbody = document.querySelector(`#${uuid.value} tbody`) as HTMLElement
|
||||
Sortable.create(tbody, {
|
||||
handle: '.move',
|
||||
animation: 300,
|
||||
onEnd({ newIndex, oldIndex }) {
|
||||
const [removedItem] = processTableData.value.splice(oldIndex!, 1)
|
||||
processTableData.value.splice(newIndex!, 0, removedItem)
|
||||
emit('dragSort', { newIndex, oldIndex })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 暴露给父组件的参数和方法 (外部需要什么,都可以从这里暴露出去)
|
||||
defineExpose({
|
||||
element: tableRef,
|
||||
tableData: processTableData,
|
||||
radio,
|
||||
pageable,
|
||||
searchParam,
|
||||
searchInitParam,
|
||||
isSelected,
|
||||
selectedList,
|
||||
selectedListIds,
|
||||
|
||||
// 下面为 function
|
||||
getTableList,
|
||||
search,
|
||||
reset,
|
||||
handleSizeChange,
|
||||
handleCurrentChange,
|
||||
clearSelection,
|
||||
enumMap,
|
||||
})
|
||||
</script>
|
||||
86
frontend/src/components/ProTable/interface/index.ts
Normal file
86
frontend/src/components/ProTable/interface/index.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { VNode, ComponentPublicInstance, Ref } from "vue";
|
||||
import { BreakPoint, Responsive } from "@/components/Grid/interface";
|
||||
import { TableColumnCtx } from "element-plus/es/components/table/src/table-column/defaults";
|
||||
import { ProTableProps } from "@/components/ProTable/index.vue";
|
||||
import ProTable from "@/components/ProTable/index.vue";
|
||||
|
||||
export interface EnumProps {
|
||||
label?: string; // 选项框显示的文字
|
||||
value?: string | number | boolean | any[]; // 选项框值
|
||||
disabled?: boolean; // 是否禁用此选项
|
||||
tagType?: string; // 当 tag 为 true 时,此选择会指定 tag 显示类型
|
||||
children?: EnumProps[]; // 为树形选择时,可以通过 children 属性指定子选项
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export type TypeProps = "index" | "selection" | "radio" | "expand" | "sort";
|
||||
|
||||
export type SearchType =
|
||||
| "input"
|
||||
| "input-number"
|
||||
| "select"
|
||||
| "select-v2"
|
||||
| "tree-select"
|
||||
| "cascader"
|
||||
| "date-picker"
|
||||
| "time-picker"
|
||||
| "time-select"
|
||||
| "switch"
|
||||
| "slider";
|
||||
|
||||
export type SearchRenderScope = {
|
||||
searchParam: { [key: string]: any };
|
||||
placeholder: string;
|
||||
clearable: boolean;
|
||||
options: EnumProps[];
|
||||
data: EnumProps[];
|
||||
};
|
||||
|
||||
export type SearchProps = {
|
||||
el?: SearchType; // 当前项搜索框的类型
|
||||
label?: string; // 当前项搜索框的 label
|
||||
props?: any; // 搜索项参数,根据 element plus 官方文档来传递,该属性所有值会透传到组件
|
||||
key?: string; // 当搜索项 key 不为 prop 属性时,可通过 key 指定
|
||||
tooltip?: string; // 搜索提示
|
||||
order?: number; // 搜索项排序(从大到小)
|
||||
span?: number; // 搜索项所占用的列数,默认为 1 列
|
||||
offset?: number; // 搜索字段左侧偏移列数
|
||||
defaultValue?: string | number | boolean | any[] | Ref<any>; // 搜索项默认值
|
||||
render?: (scope: SearchRenderScope) => VNode; // 自定义搜索内容渲染(tsx语法)
|
||||
} & Partial<Record<BreakPoint, Responsive>>;
|
||||
|
||||
export type FieldNamesProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
children?: string;
|
||||
};
|
||||
|
||||
export type RenderScope<T> = {
|
||||
row: T;
|
||||
$index: number;
|
||||
column: TableColumnCtx<T>;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export type HeaderRenderScope<T> = {
|
||||
$index: number;
|
||||
column: TableColumnCtx<T>;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export interface ColumnProps<T = any>
|
||||
extends Partial<Omit<TableColumnCtx<T>, "type" | "children" | "renderCell" | "renderHeader">> {
|
||||
type?: TypeProps; // 列类型
|
||||
tag?: boolean | Ref<boolean>; // 是否是标签展示
|
||||
isShow?: boolean | Ref<boolean>; // 是否显示在表格当中
|
||||
isSetting?: boolean | Ref<boolean>; // 是否在 ColSetting 中可配置
|
||||
search?: SearchProps | undefined; // 搜索项配置
|
||||
enum?: EnumProps[] | Ref<EnumProps[]> | ((params?: any) => Promise<any>); // 枚举字典
|
||||
isFilterEnum?: boolean | Ref<boolean>; // 当前单元格值是否根据 enum 格式化(示例:enum 只作为搜索项数据)
|
||||
fieldNames?: FieldNamesProps; // 指定 label && value && children 的 key 值
|
||||
headerRender?: (scope: HeaderRenderScope<T>) => VNode; // 自定义表头内容渲染(tsx语法)
|
||||
render?: (scope: RenderScope<T>) => VNode | string; // 自定义单元格内容渲染(tsx语法)
|
||||
_children?: ColumnProps<T>[]; // 多级表头
|
||||
}
|
||||
|
||||
export type ProTableInstance = Omit<InstanceType<typeof ProTable>, keyof ComponentPublicInstance | keyof ProTableProps>;
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<component
|
||||
:is="column.search?.render ?? `el-${column.search?.el}`"
|
||||
v-bind="{ ...handleSearchProps, ...placeholder, searchParam: _searchParam, clearable }"
|
||||
v-model.trim="_searchParam[column.search?.key ?? handleProp(column.prop!)]"
|
||||
:data="column.search?.el === 'tree-select' ? columnEnum : []"
|
||||
:options="['cascader', 'select-v2'].includes(column.search?.el!) ? columnEnum : []"
|
||||
>
|
||||
<template v-if="column.search?.el === 'cascader'" #default="{ data }">
|
||||
<span>{{ data[fieldNames.label] }}</span>
|
||||
</template>
|
||||
<template v-if="column.search?.el === 'select'">
|
||||
<component
|
||||
:is="`el-option`"
|
||||
v-for="(col, index) in columnEnum"
|
||||
:key="index"
|
||||
:label="col[fieldNames.label]"
|
||||
:value="col[fieldNames.value]"
|
||||
></component>
|
||||
</template>
|
||||
<slot v-else></slot>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="SearchFormItem">
|
||||
import { computed, inject, ref } from "vue";
|
||||
import { handleProp } from "@/utils";
|
||||
import { ColumnProps } from "@/components/ProTable/interface";
|
||||
|
||||
interface SearchFormItem {
|
||||
column: ColumnProps;
|
||||
searchParam: { [key: string]: any };
|
||||
}
|
||||
const props = defineProps<SearchFormItem>();
|
||||
|
||||
// Re receive SearchParam
|
||||
const _searchParam = computed(() => props.searchParam);
|
||||
|
||||
// 判断 fieldNames 设置 label && value && children 的 key 值
|
||||
const fieldNames = computed(() => {
|
||||
return {
|
||||
label: props.column.fieldNames?.label ?? "label",
|
||||
value: props.column.fieldNames?.value ?? "value",
|
||||
children: props.column.fieldNames?.children ?? "children"
|
||||
};
|
||||
});
|
||||
|
||||
// 接收 enumMap (el 为 select-v2 需单独处理 enumData)
|
||||
const enumMap = inject("enumMap", ref(new Map()));
|
||||
const columnEnum = computed(() => {
|
||||
let enumData = enumMap.value.get(props.column.prop);
|
||||
if (!enumData) return [];
|
||||
if (props.column.search?.el === "select-v2" && props.column.fieldNames) {
|
||||
enumData = enumData.map((item: { [key: string]: any }) => {
|
||||
return { ...item, label: item[fieldNames.value.label], value: item[fieldNames.value.value] };
|
||||
});
|
||||
}
|
||||
return enumData;
|
||||
});
|
||||
|
||||
// 处理透传的 searchProps (el 为 tree-select、cascader 的时候需要给下默认 label && value && children)
|
||||
const handleSearchProps = computed(() => {
|
||||
const label = fieldNames.value.label;
|
||||
const value = fieldNames.value.value;
|
||||
const children = fieldNames.value.children;
|
||||
const searchEl = props.column.search?.el;
|
||||
let searchProps = props.column.search?.props ?? {};
|
||||
if (searchEl === "tree-select") {
|
||||
searchProps = { ...searchProps, props: { ...searchProps, label, children }, nodeKey: value };
|
||||
}
|
||||
if (searchEl === "cascader") {
|
||||
searchProps = { ...searchProps, props: { ...searchProps, label, value, children } };
|
||||
}
|
||||
return searchProps;
|
||||
});
|
||||
|
||||
// 处理默认 placeholder
|
||||
const placeholder = computed(() => {
|
||||
const search = props.column.search;
|
||||
if (["datetimerange", "daterange", "monthrange"].includes(search?.props?.type) || search?.props?.isRange) {
|
||||
return {
|
||||
rangeSeparator: search?.props?.rangeSeparator ?? "至",
|
||||
startPlaceholder: search?.props?.startPlaceholder ?? "开始时间",
|
||||
endPlaceholder: search?.props?.endPlaceholder ?? "结束时间"
|
||||
};
|
||||
}
|
||||
const placeholder = search?.props?.placeholder ?? (search?.el?.includes("input") ? "请输入" : "请选择");
|
||||
return { placeholder };
|
||||
});
|
||||
|
||||
// 是否有清除按钮 (当搜索项有默认值时,清除按钮不显示)
|
||||
const clearable = computed(() => {
|
||||
const search = props.column.search;
|
||||
return search?.props?.clearable ?? (search?.defaultValue == null || search?.defaultValue == undefined);
|
||||
});
|
||||
</script>
|
||||
96
frontend/src/components/SearchForm/index.vue
Normal file
96
frontend/src/components/SearchForm/index.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div v-if='columns.length' class='card table-search'>
|
||||
<el-form ref='formRef' :model='searchParam'>
|
||||
<Grid ref='gridRef' :collapsed='collapsed' :gap='[20, 0]' :cols='searchCol'>
|
||||
<GridItem v-for='(item, index) in columns' :key='item.prop' v-bind='getResponsive(item)' :index='index'>
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<el-space :size='4'>
|
||||
<span>{{ `${item.search?.label ?? item.label}` }}</span>
|
||||
<el-tooltip v-if='item.search?.tooltip' effect='dark' :content='item.search?.tooltip' placement='top'>
|
||||
<i :class="'iconfont icon-yiwen'"></i>
|
||||
</el-tooltip>
|
||||
</el-space>
|
||||
<span> :</span>
|
||||
</template>
|
||||
<SearchFormItem :column='item' :search-param='searchParam' />
|
||||
</el-form-item>
|
||||
</GridItem>
|
||||
<GridItem suffix>
|
||||
<div class='operation'>
|
||||
<el-button type='primary' :icon='Search' @click='search'> 搜索</el-button>
|
||||
<el-button :icon='Delete' @click='reset'> 重置</el-button>
|
||||
<el-button v-if='showCollapse' type='primary' link class='search-isOpen' @click='collapsed = !collapsed'>
|
||||
{{ collapsed ? '展开' : '合并' }}
|
||||
<el-icon class='el-icon--right'>
|
||||
<component :is='collapsed ? ArrowDown : ArrowUp'></component>
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang='ts' name='SearchForm'>
|
||||
import { ColumnProps } from '@/components/ProTable/interface'
|
||||
import { BreakPoint } from '@/components/Grid/interface'
|
||||
import { Delete, Search, ArrowDown, ArrowUp } from '@element-plus/icons-vue'
|
||||
import SearchFormItem from './components/SearchFormItem.vue'
|
||||
import Grid from '@/components/Grid/index.vue'
|
||||
import GridItem from '@/components/Grid/components/GridItem.vue'
|
||||
|
||||
|
||||
interface ProTableProps {
|
||||
columns?: ColumnProps[]; // 搜索配置列
|
||||
searchParam?: {
|
||||
[key: string]: any
|
||||
}; // 搜索参数
|
||||
searchCol: number | Record<BreakPoint, number>;
|
||||
search: (params: any) => void; // 搜索方法
|
||||
reset: (params: any) => void; // 重置方法
|
||||
}
|
||||
|
||||
// 默认值
|
||||
const props = withDefaults(defineProps<ProTableProps>(), {
|
||||
columns: () => [],
|
||||
searchParam: () => ({}),
|
||||
})
|
||||
|
||||
// 获取响应式设置
|
||||
const getResponsive = (item: ColumnProps) => {
|
||||
return {
|
||||
span: item.search?.span,
|
||||
offset: item.search?.offset ?? 0,
|
||||
xs: item.search?.xs,
|
||||
sm: item.search?.sm,
|
||||
md: item.search?.md,
|
||||
lg: item.search?.lg,
|
||||
xl: item.search?.xl,
|
||||
}
|
||||
}
|
||||
|
||||
// 是否默认折叠搜索项
|
||||
const collapsed = ref(true)
|
||||
|
||||
// 获取响应式断点
|
||||
const gridRef = ref()
|
||||
const breakPoint = computed<BreakPoint>(() => gridRef.value?.breakPoint)
|
||||
|
||||
// 判断是否显示 展开/合并 按钮
|
||||
const showCollapse = computed(() => {
|
||||
let show = false
|
||||
props.columns.reduce((prev, current) => {
|
||||
prev +=
|
||||
(current.search![breakPoint.value]?.span ?? current.search?.span ?? 1) +
|
||||
(current.search![breakPoint.value]?.offset ?? current.search?.offset ?? 0)
|
||||
if (typeof props.searchCol !== 'number') {
|
||||
if (prev >= props.searchCol[breakPoint.value]) show = true
|
||||
} else {
|
||||
if (prev >= props.searchCol) show = true
|
||||
}
|
||||
return prev
|
||||
}, 0)
|
||||
return show
|
||||
})
|
||||
</script>
|
||||
39
frontend/src/components/SelectIcon/index.scss
Normal file
39
frontend/src/components/SelectIcon/index.scss
Normal file
@@ -0,0 +1,39 @@
|
||||
.icon-box {
|
||||
width: 100%;
|
||||
.el-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
:deep(.el-dialog__body) {
|
||||
padding: 25px 20px 20px;
|
||||
.el-input {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.icon-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, 115px);
|
||||
justify-content: space-evenly;
|
||||
max-height: 70vh;
|
||||
.icon-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 42px;
|
||||
padding: 20px 30px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
&:hover {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
span {
|
||||
margin-top: 5px;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
90
frontend/src/components/SelectIcon/index.vue
Normal file
90
frontend/src/components/SelectIcon/index.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div class="icon-box">
|
||||
<el-input
|
||||
ref="inputRef"
|
||||
v-model="valueIcon"
|
||||
v-bind="$attrs"
|
||||
:placeholder="placeholder"
|
||||
:clearable="clearable"
|
||||
@clear="clearIcon"
|
||||
@click="openDialog"
|
||||
>
|
||||
<template #append>
|
||||
<el-button :icon="customIcons[iconValue]" />
|
||||
</template>
|
||||
</el-input>
|
||||
<el-dialog v-model="dialogVisible" :title="placeholder" top="5%" width="30%">
|
||||
<el-input v-model="inputValue" placeholder="搜索图标" size="large" :prefix-icon="Icons.Search" />
|
||||
<el-scrollbar v-if="Object.keys(iconsList).length">
|
||||
<div class="icon-list">
|
||||
<div v-for="item in iconsList" :key="item" class="icon-item" @click="selectIcon(item)">
|
||||
<component :is="item"></component>
|
||||
<span>{{ item.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
<el-empty v-else description="未搜索到您要找的图标~" />
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup name="SelectIcon">
|
||||
import * as Icons from '@element-plus/icons-vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
interface SelectIconProps {
|
||||
iconValue: string | undefined
|
||||
title?: string
|
||||
clearable?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<SelectIconProps>(), {
|
||||
iconValue: '',
|
||||
title: '请选择图标',
|
||||
clearable: true,
|
||||
placeholder: '请选择图标'
|
||||
})
|
||||
|
||||
// 重新接收一下,防止打包后 clearable 报错
|
||||
const valueIcon = ref(props.iconValue)
|
||||
|
||||
// open Dialog
|
||||
const dialogVisible = ref(false)
|
||||
const openDialog = () => (dialogVisible.value = true)
|
||||
|
||||
// 选择图标(触发更新父组件数据)
|
||||
const emit = defineEmits<{
|
||||
'update:iconValue': [value: string]
|
||||
}>()
|
||||
const selectIcon = (item: any) => {
|
||||
dialogVisible.value = false
|
||||
valueIcon.value = item.name
|
||||
emit('update:iconValue', item.name)
|
||||
setTimeout(() => inputRef.value.blur(), 0)
|
||||
}
|
||||
|
||||
// 清空图标
|
||||
const inputRef = ref()
|
||||
const clearIcon = () => {
|
||||
valueIcon.value = ''
|
||||
emit('update:iconValue', '')
|
||||
setTimeout(() => inputRef.value.blur(), 0)
|
||||
}
|
||||
|
||||
// 监听搜索框值
|
||||
const inputValue = ref('')
|
||||
const customIcons: { [key: string]: any } = Icons
|
||||
const iconsList = computed((): { [key: string]: any } => {
|
||||
if (!inputValue.value) return Icons
|
||||
let result: { [key: string]: any } = {}
|
||||
for (const key in customIcons) {
|
||||
if (key.toLowerCase().indexOf(inputValue.value.toLowerCase()) > -1) result[key] = customIcons[key]
|
||||
}
|
||||
return result
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use './index.scss';
|
||||
</style>
|
||||
2
frontend/src/components/StaticExtend/SvgIcon/index.ts
Normal file
2
frontend/src/components/StaticExtend/SvgIcon/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import SvgIcon from './src/SvgIcon.vue'
|
||||
export { SvgIcon }
|
||||
88
frontend/src/components/StaticExtend/SvgIcon/src/SvgIcon.vue
Normal file
88
frontend/src/components/StaticExtend/SvgIcon/src/SvgIcon.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @name SvgIcon
|
||||
* @description svg图标组件
|
||||
* 支持定义名称、颜色、大小、旋转
|
||||
* @example <SvgIcon name="icon-name" color="#fff" size="20" spin />
|
||||
*/
|
||||
<template>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
:class="['svg-icon', spin && 'svg-icon-spin']"
|
||||
:style="getStyle"
|
||||
>
|
||||
<use :xlink:href="symbolId" :fill="color" />
|
||||
</svg>
|
||||
</template>
|
||||
<script setup lang='ts'>
|
||||
defineOptions({
|
||||
name: 'SvgIcon',
|
||||
})
|
||||
import type { CSSProperties } from 'vue'
|
||||
// 定义组件对外暴露的props
|
||||
const props = defineProps({
|
||||
prefix: {
|
||||
type: String,
|
||||
default: 'icon',
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
size: {
|
||||
type: [Number, String],
|
||||
default: 20,
|
||||
},
|
||||
spin: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
//计算属性获取symbolId
|
||||
const symbolId = computed(() => {
|
||||
return `#${props.prefix}-${props.name}`
|
||||
})
|
||||
|
||||
//计算属性获取svg样式
|
||||
const getStyle = computed((): CSSProperties => {
|
||||
const { size } = props
|
||||
let s = `${size}`
|
||||
// 确保size为px单位
|
||||
s = `${s.replace('px', '')}px`
|
||||
return {
|
||||
width: s,
|
||||
height: s,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
</script>
|
||||
<style scoped>
|
||||
|
||||
.svg-icon {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
vertical-align: -0.15em;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.svg-icon-spin {
|
||||
animation: loadingCircle 1.2s infinite linear;
|
||||
}
|
||||
|
||||
/* 旋转动画 */
|
||||
@keyframes loadingCircle {
|
||||
0% {
|
||||
transform: rotate(0);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
12
frontend/src/components/SwitchDark/index.vue
Normal file
12
frontend/src/components/SwitchDark/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<el-switch v-model="globalStore.isDark" inline-prompt :active-icon="Sunny" :inactive-icon="Moon" @change="switchDark" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="SwitchDark">
|
||||
import { useTheme } from "@/hooks/useTheme";
|
||||
import { useGlobalStore } from "@/stores/modules/global";
|
||||
import { Sunny, Moon } from "@element-plus/icons-vue";
|
||||
|
||||
const { switchDark } = useTheme();
|
||||
const globalStore = useGlobalStore();
|
||||
</script>
|
||||
42
frontend/src/components/TimeControl/index.scss
Normal file
42
frontend/src/components/TimeControl/index.scss
Normal file
@@ -0,0 +1,42 @@
|
||||
/* 添加样式 */
|
||||
.time-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.select {
|
||||
margin-right: 10px; /* 下拉框右侧间距 */
|
||||
width: 90px; /* 下拉框宽度 */
|
||||
}
|
||||
|
||||
.date-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 10px; /* 日期选择器右侧间距 */
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.date-picker {
|
||||
margin-right: 10px; /* 日期选择器之间的间距 */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.triangle-button {
|
||||
margin: 0 2px; /* 设置左右间距 */
|
||||
}
|
||||
|
||||
.left_triangle {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 10px solid transparent; /* 上边透明 */
|
||||
border-bottom: 10px solid transparent; /* 下边透明 */
|
||||
border-right: 15px solid white; /* 左边为白色 */
|
||||
}
|
||||
|
||||
.right_triangle {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 10px solid transparent; /* 上边透明 */
|
||||
border-bottom: 10px solid transparent; /* 下边透明 */
|
||||
border-left: 15px solid white; /* 左边为白色 */
|
||||
}
|
||||
292
frontend/src/components/TimeControl/index.vue
Normal file
292
frontend/src/components/TimeControl/index.vue
Normal file
@@ -0,0 +1,292 @@
|
||||
<template>
|
||||
<div class="time-control">
|
||||
<el-select class="select" v-model="timeUnit" placeholder="选择时间单位" @change="handleChange">
|
||||
<!-- 采用 v-for 动态渲染 -->
|
||||
<el-option v-for="unit in timeUnits" :key="unit.value" :label="unit.label" :value="unit.value"></el-option>
|
||||
</el-select>
|
||||
|
||||
<!-- 禁用时间选择器 -->
|
||||
<div class="date-display">
|
||||
<el-date-picker
|
||||
class="date-picker"
|
||||
v-model="startDate"
|
||||
type="date"
|
||||
placeholder="起始时间"
|
||||
@change="emitDateChange"
|
||||
:disabled-date="disableStartDate"
|
||||
:readonly="timeUnit != '自定义'"
|
||||
></el-date-picker>
|
||||
<el-text>~</el-text>
|
||||
<el-date-picker
|
||||
class="date-picker"
|
||||
v-model="endDate"
|
||||
type="date"
|
||||
placeholder="结束时间"
|
||||
@change="emitDateChange"
|
||||
:disabled-date="disableEndDate"
|
||||
:readonly="timeUnit !== '自定义'"
|
||||
></el-date-picker>
|
||||
</div>
|
||||
<div class="date-display" v-if="timeUnit !== '自定义'">
|
||||
<el-button
|
||||
style="width: 10px"
|
||||
class="triangle-button"
|
||||
type="primary"
|
||||
@click="prevPeriod"
|
||||
@change="emitDateChange"
|
||||
>
|
||||
<div class="left_triangle"></div>
|
||||
</el-button>
|
||||
<el-button class="triangle-button" type="primary" @click="goToCurrent">当前</el-button>
|
||||
<el-button
|
||||
style="width: 10px"
|
||||
class="triangle-button"
|
||||
type="primary"
|
||||
@click="nextPeriod"
|
||||
:disabled="isNextDisabled"
|
||||
>
|
||||
<div class="right_triangle"></div>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
// 定义时间单位的类型
|
||||
interface TimeUnit {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
// 定义组件的props,包含包括和排除的时间单位
|
||||
const props = defineProps({
|
||||
include: {
|
||||
type: Array as () => string[],
|
||||
default: () => ['日', '周', '月', '季度', '年', '自定义']
|
||||
},
|
||||
exclude: {
|
||||
type: Array as () => string[],
|
||||
default: () => []
|
||||
},
|
||||
default: {
|
||||
type: String,
|
||||
default: '月'
|
||||
}
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits<{
|
||||
(e: 'update-dates', startDate: string, endDate: string): void
|
||||
}>()
|
||||
const timeUnit = ref<string>(props.default) // 默认选择
|
||||
const startDate = ref<Date>(new Date()) // 起始日期
|
||||
const endDate = ref<Date>(new Date()) // 结束日期
|
||||
const isNextDisabled = ref<boolean>(false) // 控制下一周期按钮的禁用状态
|
||||
const today = ref<Date>(new Date()) // 当前日期
|
||||
// 过滤出可用的时间单位
|
||||
const timeUnits = ref<TimeUnit[]>(
|
||||
props.include
|
||||
.filter(unit => !props.exclude.includes(unit))
|
||||
.map(unit => ({
|
||||
label: unit,
|
||||
value: unit
|
||||
}))
|
||||
)
|
||||
|
||||
// 发出日期变化事件
|
||||
const emitDateChange = () => {
|
||||
emit('update-dates', formatDate(startDate.value), formatDate(endDate.value))
|
||||
}
|
||||
|
||||
// 在组件挂载时更新日期范围
|
||||
onMounted(() => {
|
||||
updateDateRange()
|
||||
})
|
||||
const handleChange = (unit: string) => {
|
||||
// 根据选择的时间单位处理日期变化
|
||||
if (unit !== '自定义') {
|
||||
updateDateRange()
|
||||
} else {
|
||||
// 自定义选项逻辑
|
||||
startDate.value = new Date(new Date().setDate(new Date().getDate() - 1))
|
||||
endDate.value = new Date()
|
||||
}
|
||||
timeUnit.value = unit
|
||||
|
||||
// 确保开始时间和结束时间不为空
|
||||
if (!startDate.value) {
|
||||
startDate.value = new Date()
|
||||
}
|
||||
if (!endDate.value) {
|
||||
endDate.value = new Date()
|
||||
}
|
||||
|
||||
emitDateChange() // 变化时也发出更新事件
|
||||
updateNextButtonStatus()
|
||||
}
|
||||
const updateDateRange = () => {
|
||||
// 根据选择的时间单位计算起始和结束日期
|
||||
if (timeUnit.value === '日') {
|
||||
startDate.value = today.value
|
||||
endDate.value = today.value
|
||||
} else if (timeUnit.value === '周') {
|
||||
startDate.value = getStartOfWeek(today.value)
|
||||
endDate.value = getEndOfWeek(today.value)
|
||||
} else if (timeUnit.value === '月') {
|
||||
// 获取本月的开始和结束日期
|
||||
startDate.value = new Date(today.value.getFullYear(), today.value.getMonth(), 1)
|
||||
endDate.value = new Date(today.value.getFullYear(), today.value.getMonth() + 1, 0)
|
||||
|
||||
// // 确保结束日期不超过今天
|
||||
// if (endDate.value > today.value) {
|
||||
// endDate.value = new Date(today.value);
|
||||
// endDate.value.setHours(23, 59, 59, 999); // 设置结束时间为今天的23:59:59.999
|
||||
// }
|
||||
} else if (timeUnit.value === '季度') {
|
||||
const quarter = Math.floor(today.value.getMonth() / 3)
|
||||
startDate.value = new Date(today.value.getFullYear(), quarter * 3, 1)
|
||||
endDate.value = new Date(today.value.getFullYear(), quarter * 3 + 3, 0)
|
||||
|
||||
// // 确保结束日期不超过今天
|
||||
// if (endDate.value > today.value) {
|
||||
// endDate.value = new Date(today.value);
|
||||
// endDate.value.setHours(23, 59, 59, 999); // 设置结束时间为今天的23:59:59.999
|
||||
// }
|
||||
} else if (timeUnit.value === '年') {
|
||||
startDate.value = new Date(today.value.getFullYear(), 0, 1)
|
||||
endDate.value = new Date(today.value.getFullYear(), 11, 31)
|
||||
|
||||
// // 确保结束日期不超过今天
|
||||
// if (endDate.value > today.value) {
|
||||
|
||||
// endDate.value = new Date(today.value);
|
||||
// endDate.value.setHours(23, 59, 59, 999); // 设置结束时间为今天的23:59:59.999
|
||||
// }
|
||||
}
|
||||
// 确保开始时间和结束时间不为空
|
||||
if (!startDate.value) {
|
||||
startDate.value = new Date()
|
||||
}
|
||||
if (!endDate.value) {
|
||||
endDate.value = new Date()
|
||||
}
|
||||
|
||||
updateNextButtonStatus()
|
||||
}
|
||||
const getStartOfWeek = (date: Date) => {
|
||||
const startOfWeek = new Date(date)
|
||||
const day = startOfWeek.getDay()
|
||||
const diff = day === 0 ? -6 : 1 - day // 星期天的情况
|
||||
startOfWeek.setDate(startOfWeek.getDate() + diff)
|
||||
return startOfWeek
|
||||
}
|
||||
const getEndOfWeek = (date: Date) => {
|
||||
const endOfWeek = new Date(date)
|
||||
const day = endOfWeek.getDay()
|
||||
const diff = day === 0 ? 0 : 7 - day // 星期天的情况
|
||||
endOfWeek.setDate(endOfWeek.getDate() + diff)
|
||||
|
||||
// 获取今天的日期
|
||||
const today = new Date()
|
||||
today.setHours(23, 59, 59, 999) // 设置今天的结束时间(23:59:59.999)
|
||||
|
||||
// 返回不超过今天的结束时间
|
||||
//return endOfWeek > today ? today : endOfWeek;
|
||||
return endOfWeek
|
||||
}
|
||||
const prevPeriod = () => {
|
||||
const prevStartDate = new Date(startDate.value)
|
||||
const prevEndDate = new Date(endDate.value)
|
||||
|
||||
if (timeUnit.value === '日') {
|
||||
prevStartDate.setDate(prevStartDate.getDate() - 1)
|
||||
prevEndDate.setDate(prevEndDate.getDate() - 1)
|
||||
} else if (timeUnit.value === '周') {
|
||||
prevStartDate.setDate(prevStartDate.getDate() - 7)
|
||||
prevEndDate.setDate(prevEndDate.getDate() - 7)
|
||||
} else if (timeUnit.value === '月') {
|
||||
prevStartDate.setMonth(prevStartDate.getMonth() - 1)
|
||||
prevEndDate.setMonth(prevEndDate.getMonth() - 1)
|
||||
} else if (timeUnit.value === '季度') {
|
||||
prevStartDate.setMonth(prevStartDate.getMonth() - 3)
|
||||
prevEndDate.setMonth(prevEndDate.getMonth() - 3)
|
||||
} else if (timeUnit.value === '年') {
|
||||
prevStartDate.setFullYear(prevStartDate.getFullYear() - 1)
|
||||
prevEndDate.setFullYear(prevEndDate.getFullYear() - 1)
|
||||
}
|
||||
|
||||
startDate.value = prevStartDate
|
||||
endDate.value = prevEndDate
|
||||
updateNextButtonStatus()
|
||||
}
|
||||
const goToCurrent = () => {
|
||||
if (timeUnit.value !== '自定义') {
|
||||
updateDateRange() // 更新为当前选择时间单位的时间范围
|
||||
}
|
||||
}
|
||||
const nextPeriod = () => {
|
||||
const nextStartDate = new Date(startDate.value)
|
||||
const nextEndDate = new Date(endDate.value)
|
||||
|
||||
if (timeUnit.value === '日') {
|
||||
nextStartDate.setDate(nextStartDate.getDate() + 1)
|
||||
nextEndDate.setDate(nextEndDate.getDate() + 1)
|
||||
} else if (timeUnit.value === '周') {
|
||||
nextStartDate.setDate(nextStartDate.getDate() + 7)
|
||||
nextEndDate.setDate(nextEndDate.getDate() + 7)
|
||||
} else if (timeUnit.value === '月') {
|
||||
nextStartDate.setMonth(nextStartDate.getMonth() + 1)
|
||||
nextEndDate.setMonth(nextEndDate.getMonth() + 1)
|
||||
} else if (timeUnit.value === '季度') {
|
||||
nextStartDate.setMonth(nextStartDate.getMonth() + 3)
|
||||
nextEndDate.setMonth(nextStartDate.getMonth() + 3)
|
||||
} else if (timeUnit.value === '年') {
|
||||
nextStartDate.setFullYear(nextStartDate.getFullYear() + 1)
|
||||
nextEndDate.setFullYear(nextEndDate.getFullYear() + 1)
|
||||
}
|
||||
|
||||
startDate.value = nextStartDate
|
||||
endDate.value = nextEndDate
|
||||
updateNextButtonStatus()
|
||||
}
|
||||
const updateNextButtonStatus = () => {
|
||||
// 更新下一个按钮的禁用状态
|
||||
const maxDate = new Date() // 假设最新日期为今天
|
||||
// 将 maxDate 设置为当天的开始时间
|
||||
maxDate.setHours(0, 0, 0, 0)
|
||||
// 将 endDate 设置为当天的开始时间并进行比较
|
||||
const endDateAdjusted = new Date(endDate.value)
|
||||
endDateAdjusted.setHours(0, 0, 0, 0)
|
||||
// 仅比较日期部分
|
||||
isNextDisabled.value = endDateAdjusted >= maxDate
|
||||
emitDateChange() // 变化时也发出更新事件
|
||||
}
|
||||
|
||||
// 限制开始日期不能选择超过当前日期
|
||||
const disableStartDate = (date: Date) => {
|
||||
return date > today.value
|
||||
}
|
||||
// 限制结束日期不能超过当前日期且必须大于开始日期
|
||||
const disableEndDate = (date: Date) => {
|
||||
if (timeUnit.value !== '自定义') return false // 如果不是自定义时间单位,则不限制
|
||||
const start = new Date(startDate.value)
|
||||
return date > today.value || (start && date <= start)
|
||||
}
|
||||
|
||||
// 格式化日期yyyy-mm-dd
|
||||
function formatDate(date: Date | null): string {
|
||||
if (!date) {
|
||||
return ''
|
||||
}
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use './index.scss';
|
||||
</style>
|
||||
286
frontend/src/components/echarts/line/index.vue
Normal file
286
frontend/src/components/echarts/line/index.vue
Normal file
@@ -0,0 +1,286 @@
|
||||
<template>
|
||||
<div class="chart">
|
||||
<div ref="chartRef" class="my-chart" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
// import echarts from './echarts'
|
||||
import * as echarts from 'echarts' // 全引入
|
||||
// import 'echarts-gl'
|
||||
// import 'echarts-liquidfill'
|
||||
// import 'echarts/lib/component/dataZoom'
|
||||
|
||||
const color = [
|
||||
'var(--el-color-primary)',
|
||||
'#07CCCA',
|
||||
'#00BFF5',
|
||||
'#FFBF00',
|
||||
'#77DA63',
|
||||
'#D5FF6B',
|
||||
'#Ff6600',
|
||||
'#FF9100',
|
||||
'#5B6E96',
|
||||
'#66FFCC',
|
||||
'#B3B3B3'
|
||||
]
|
||||
|
||||
const chartRef = ref<HTMLDivElement>()
|
||||
|
||||
const props = defineProps(['options', 'isInterVal', 'pieInterVal'])
|
||||
let chart: echarts.ECharts | any = null
|
||||
const resizeHandler = () => {
|
||||
// 不在视野中的时候不进行resize
|
||||
if (!chartRef.value) return
|
||||
if (chartRef.value.offsetHeight == 0) return
|
||||
chart.getZr().painter.getViewportRoot().style.display = 'none'
|
||||
requestAnimationFrame(() => {
|
||||
chart.resize()
|
||||
chart.getZr().painter.getViewportRoot().style.display = ''
|
||||
})
|
||||
}
|
||||
const initChart = () => {
|
||||
|
||||
if (!props.isInterVal && !props.pieInterVal) {
|
||||
chart?.dispose()
|
||||
}
|
||||
// chart?.dispose()
|
||||
chart = echarts.init(chartRef.value as HTMLDivElement)
|
||||
|
||||
const options = {
|
||||
title: {
|
||||
left: 'center',
|
||||
// textStyle: {
|
||||
color: '#000',
|
||||
fontSize: 18,
|
||||
// },
|
||||
...(props.options?.title || null)
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow',
|
||||
label: {
|
||||
color: '#fff',
|
||||
fontSize: 16
|
||||
}
|
||||
},
|
||||
textStyle: {
|
||||
color: '#fff',
|
||||
fontStyle: 'normal',
|
||||
opacity: 0.35,
|
||||
fontSize: 14
|
||||
},
|
||||
backgroundColor: 'rgba(0,0,0,0.55)',
|
||||
borderWidth: 0,
|
||||
confine: true,
|
||||
...(props.options?.tooltip || null)
|
||||
},
|
||||
|
||||
legend: {
|
||||
right: 20,
|
||||
top: 0,
|
||||
itemGap: 10,
|
||||
itemStyle: {},
|
||||
// textStyle: {
|
||||
fontSize: 12,
|
||||
padding: [2, 0, 0, 0], //[上、右、下、左]
|
||||
// },
|
||||
itemWidth: 15,
|
||||
itemHeight: 10,
|
||||
...(props.options?.legend || null)
|
||||
},
|
||||
grid: {
|
||||
top: '60px',
|
||||
left: '30px',
|
||||
right: '70px',
|
||||
bottom: props.options?.options?.dataZoom === null ? '10px' : '40px',
|
||||
containLabel: true,
|
||||
...(props.options?.grid || null)
|
||||
},
|
||||
xAxis: props.options?.xAxis ? handlerXAxis() : null,
|
||||
yAxis: props.options?.yAxis ? handlerYAxis() : null,
|
||||
dataZoom: props.options?.dataZoom || [
|
||||
{
|
||||
type: 'inside',
|
||||
height: 13,
|
||||
start: 0,
|
||||
bottom: '20px',
|
||||
end: 100
|
||||
},
|
||||
{
|
||||
start: 0,
|
||||
height: 13,
|
||||
bottom: '20px',
|
||||
end: 100
|
||||
}
|
||||
],
|
||||
color: props.options?.color || color,
|
||||
series: props.options?.series,
|
||||
...props.options?.options
|
||||
}
|
||||
// console.log(options.series,"获取x轴");
|
||||
handlerBar(options)
|
||||
|
||||
// 处理柱状图
|
||||
chart.setOption(options, true)
|
||||
|
||||
setTimeout(() => {
|
||||
chart.resize()
|
||||
}, 0)
|
||||
}
|
||||
const handlerBar = (options: any) => {
|
||||
if (Array.isArray(options.series)) {
|
||||
options.series.forEach((item: any) => {
|
||||
if (item.type === 'bar') {
|
||||
item.barMinHeight = 10
|
||||
item.barMaxWidth = 20
|
||||
item.itemStyle = Object.assign(
|
||||
{
|
||||
color: (params: any) => {
|
||||
if (params.value == 0 || params.value == 3.14159) {
|
||||
return '#ccc'
|
||||
} else {
|
||||
return props.options?.color
|
||||
? props.options?.color[params.seriesIndex]
|
||||
: color[params.seriesIndex]
|
||||
}
|
||||
}
|
||||
},
|
||||
item.itemStyle
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
const handlerYAxis = () => {
|
||||
let temp = {
|
||||
type: 'value',
|
||||
nameGap: 15,
|
||||
nameTextStyle: {
|
||||
color: '#000'
|
||||
},
|
||||
splitNumber: 5,
|
||||
minInterval: 1,
|
||||
axisLine: {
|
||||
show: true,
|
||||
lineStyle: {
|
||||
color: '#000'
|
||||
}
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#000',
|
||||
fontSize: 14,
|
||||
formatter: function (value) {
|
||||
return value.toFixed(0) // 格式化显示为一位小数
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
// 使用深浅的间隔色
|
||||
color: ['#ccc'],
|
||||
type: 'dashed',
|
||||
opacity: 0.5
|
||||
}
|
||||
}
|
||||
}
|
||||
// props.options?.xAxis 是数组还是对象
|
||||
if (Array.isArray(props.options?.yAxis)) {
|
||||
return props.options?.yAxis.map((item: any) => {
|
||||
return {
|
||||
...temp,
|
||||
...item
|
||||
}
|
||||
})
|
||||
} else {
|
||||
return {
|
||||
...temp,
|
||||
...props.options?.yAxis
|
||||
}
|
||||
}
|
||||
}
|
||||
const handlerXAxis = () => {
|
||||
let temp = {
|
||||
type: 'category',
|
||||
axisTick: { show: false },
|
||||
nameTextStyle: {
|
||||
color: '#000'
|
||||
},
|
||||
axisLine: {
|
||||
// lineStyle: {
|
||||
color: '#000'
|
||||
// }
|
||||
},
|
||||
axisLabel: {
|
||||
// textStyle: {
|
||||
fontFamily: 'dinproRegular',
|
||||
color: '#000',
|
||||
fontSize: '12'
|
||||
// }
|
||||
}
|
||||
// boundaryGap: false,
|
||||
}
|
||||
// props.options?.xAxis 是数组还是对象
|
||||
if (Array.isArray(props.options?.xAxis)) {
|
||||
return props.options?.xAxis.map((item: any) => {
|
||||
return {
|
||||
...temp,
|
||||
...item
|
||||
}
|
||||
})
|
||||
} else {
|
||||
return {
|
||||
...temp,
|
||||
...props.options?.xAxis
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let throttle: ReturnType<typeof setTimeout>
|
||||
// 动态计算table高度
|
||||
const resizeObserver = new ResizeObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
if (throttle) {
|
||||
clearTimeout(throttle)
|
||||
}
|
||||
throttle = setTimeout(() => {
|
||||
resizeHandler()
|
||||
}, 100)
|
||||
}
|
||||
})
|
||||
onMounted(() => {
|
||||
initChart()
|
||||
resizeObserver.observe(chartRef.value!)
|
||||
})
|
||||
defineExpose({ initChart })
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver.unobserve(chartRef.value!)
|
||||
chart?.dispose()
|
||||
})
|
||||
watch(
|
||||
() => props.options,
|
||||
(newVal, oldVal) => {
|
||||
initChart()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
|
||||
.el-button {
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
top: -60px;
|
||||
}
|
||||
|
||||
.my-chart {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
192
frontend/src/components/echarts/pie/default.vue
Normal file
192
frontend/src/components/echarts/pie/default.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<!-- 默认饼图 -->
|
||||
<template>
|
||||
<div class="pie" ref="chartsRef"></div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import * as echarts from "echarts";
|
||||
import { ref } from "vue";
|
||||
const chartsRef = ref();
|
||||
const props = defineProps({
|
||||
//饼图数据
|
||||
chartsData: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
//自定义数据
|
||||
customData: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
//legend配置
|
||||
legendData: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
|
||||
});
|
||||
const customData: any = ref({}),
|
||||
legendData: any = ref({}),
|
||||
chart: any = ref();
|
||||
|
||||
const labelIsShow = ref(true)//引导线台数和鼠标点击饼图是否弹出提示显示
|
||||
const init = () => {
|
||||
|
||||
|
||||
customData.value = {
|
||||
title: "", //标题
|
||||
textAlign: "left", //标题位置可选属性left 可选属性值 left,right,center
|
||||
ratio: true, //是否显示数值占比,默认不显示
|
||||
isRing: false, //是否环形图
|
||||
isRadius: false, //是否圆角
|
||||
isSpace: false, //是否显示间隔
|
||||
isLabelLine: true, //是否显示引导线
|
||||
titleFontSize: '14px', //标题字体大小
|
||||
|
||||
...props.customData,
|
||||
|
||||
};
|
||||
legendData.value = {
|
||||
icon: "roundRect", // 图例项的icon,类型包括 circle(圆形),rect(正方形),//roundRect(圆角正方形),triangle(三角形),diamond(菱形),//pin(大头针行),arrow(箭头形),none(无图例项的icon)
|
||||
orient: "vertical", //图例排列方向
|
||||
left: "right", //可选属性left,right,top,bottom,可选属性值 left,right,top,bottom,px,百分比,数值,
|
||||
itemGap: 10, // 设置图例项之间的间隔为20
|
||||
...props.legendData,
|
||||
};
|
||||
chart.value = chartsRef.value && echarts.init(chartsRef.value);
|
||||
var option = {
|
||||
title: {
|
||||
text: customData.value.title,
|
||||
left: customData.value.textAlign,
|
||||
textStyle: {
|
||||
fontSize: customData.value.titleFontSize, // 使用 titleFontSize 属性
|
||||
},
|
||||
},
|
||||
legend:legendData.value,
|
||||
// legend: {
|
||||
// icon: legendData.value.icon, // 图例项的icon,类型包括 circle(圆形),rect(正方形),//roundRect(圆角正方形),triangle(三角形),diamond(菱形),//pin(大头针行),arrow(箭头形),none(无图例项的icon)
|
||||
// orient: "vertical", //图例排列方向
|
||||
// left: legendData.value.left, //可选属性left,right,top,bottom,可选属性值 left,right,top,bottom,px,百分比,数值,
|
||||
// itemGap: 1, // 设置图例项之间的间隔为20
|
||||
// formatter: function (name) {
|
||||
|
||||
// const item = props.chartsData.filter(item=>item.name==name)
|
||||
// console.log(item)
|
||||
// if(item)
|
||||
// return item[0].value;
|
||||
|
||||
// },
|
||||
// },
|
||||
tooltip: {
|
||||
show: labelIsShow.value,
|
||||
trigger: "item",
|
||||
formatter: customData.value.ratio ? `{b} : {c} ({d}%)` : "{b} :{c} ",
|
||||
borderWidth: 1,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: "pie",
|
||||
radius: customData.value.isRing ? ["55", "75"] : "80%",
|
||||
data: props.chartsData,
|
||||
formatter: function (name: any) {
|
||||
const item = props.chartsData.filter(item=>item.name==name)
|
||||
//console.log(item)
|
||||
if(item)
|
||||
return item[0].value;
|
||||
},
|
||||
center: ["55%", "55%"], // 设置饼图的中心位置
|
||||
// padAngle: 2,
|
||||
minAngle: 15, //最小角度
|
||||
startAngle: 270, //起始角度
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: "rgba(0, 0, 0, 0.5)",
|
||||
},
|
||||
},
|
||||
label: {
|
||||
normal: {
|
||||
show: labelIsShow.value,
|
||||
position: "outside",
|
||||
textStyle: {
|
||||
//color: "#fff",
|
||||
fontSize: 12,
|
||||
},
|
||||
formatter: function (data) {
|
||||
return labelIsShow.value ? data.value + '台' : '';
|
||||
}
|
||||
},
|
||||
},
|
||||
itemStyle: {
|
||||
borderRadius: customData.value.isRadius ? 10 : 0,
|
||||
borderColor: customData.value.isSpace ? "#fff" : "",
|
||||
borderWidth: customData.value.isSpace ? 2 : 0,
|
||||
},
|
||||
labelLine: {
|
||||
show: customData.value.isLabelLine,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
option && chart.value && chart.value.setOption(option);
|
||||
setTimeout(() => {
|
||||
chart.value.resize();
|
||||
}, 0);
|
||||
};
|
||||
const reSize = (widthValue: number,heightValue: number,silentValue: boolean) => {
|
||||
if (chart.value) {
|
||||
chart.value.resize({
|
||||
width: widthValue,
|
||||
height: heightValue,
|
||||
silent: silentValue,
|
||||
});
|
||||
}
|
||||
};
|
||||
const resizeCharts = () => {
|
||||
//console.log(chart.value,111111);
|
||||
|
||||
if (chart.value) {
|
||||
chart.value.resize();
|
||||
}
|
||||
};
|
||||
window.addEventListener("resize", resizeCharts);
|
||||
onUnmounted(() => {
|
||||
if (chart.value) {
|
||||
chart.value.resize();
|
||||
}
|
||||
window.removeEventListener("resize", resizeCharts);
|
||||
if (chart.value != null && chart.value.dispose) {
|
||||
chart.value.dispose(); // 销毁图表
|
||||
}
|
||||
});
|
||||
watch(
|
||||
() => props.chartsData,
|
||||
(val, oldVal) => {
|
||||
if (val) {
|
||||
const item = props.chartsData.find(item => item.value === 0);
|
||||
if(item != undefined){
|
||||
labelIsShow.value = false;
|
||||
}else{
|
||||
labelIsShow.value = true;
|
||||
}
|
||||
init();
|
||||
}
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
}
|
||||
);
|
||||
onMounted(() => {
|
||||
init();
|
||||
});
|
||||
defineExpose({ init,reSize });
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.pie {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
19
frontend/src/components/index.ts
Normal file
19
frontend/src/components/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { App, Component } from 'vue'
|
||||
|
||||
// 当组件很多的时候,可以使用
|
||||
import { SvgIcon } from '@/components/StaticExtend/SvgIcon'
|
||||
|
||||
// 这个地方将合并到对象中
|
||||
const Components: {
|
||||
[propName: string]: Component
|
||||
} = { SvgIcon }
|
||||
|
||||
// 批量注册全局组件
|
||||
export default {
|
||||
install: (app: App) => {
|
||||
Object.keys(Components).forEach((key) => {
|
||||
app.component(key, Components[key])
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user