<template>
|
<div class="cl-view-group" :class="[isExpand ? 'is-expand' : 'is-collapse']">
|
<div class="cl-view-group__wrap">
|
<!-- 左侧 -->
|
<div class="cl-view-group__left">
|
<slot name="left">
|
<div class="scope">
|
<div class="head">
|
<el-text class="label">{{ config.label }}</el-text>
|
|
<el-tooltip v-if="config.enableRefresh" :content="$t('刷新')">
|
<div class="icon" @click="refresh()">
|
<cl-svg name="refresh" />
|
</div>
|
</el-tooltip>
|
|
<el-tooltip v-if="config.enableAdd" :content="$t('添加')">
|
<div
|
class="icon"
|
@click="edit()"
|
v-permission="config.service.permission.add"
|
>
|
<cl-svg name="plus-border" />
|
</div>
|
</el-tooltip>
|
|
<slot name="left-op"></slot>
|
</div>
|
|
<div v-if="config.enableKeySearch" class="search">
|
<el-input
|
v-model="keyWord"
|
:placeholder="$t('搜索关键字')"
|
clearable
|
:prefix-icon="Search"
|
@change="
|
refresh({
|
page: 1
|
})
|
"
|
/>
|
</div>
|
|
<div v-loading="loading" class="data">
|
<el-scrollbar>
|
<!-- 树类型 -->
|
<template v-if="tree.visible">
|
<el-tree
|
:ref="setRefs('tree')"
|
class="tree"
|
highlight-current
|
auto-expand-parent
|
:expand-on-click-node="false"
|
:lazy="tree.lazy"
|
:data="list"
|
:props="tree.props"
|
:load="tree.onLoad"
|
@node-click="select"
|
>
|
<template #default="{ data }">
|
<div class="item">
|
<component :is="data.icon" v-if="data.icon" />
|
|
<slot
|
name="item-name"
|
:item="data"
|
:selected="selected"
|
>
|
<el-text truncated>
|
{{ data[tree.props.label] }}
|
{{
|
isEmpty(data[tree.props.children])
|
? ``
|
: `(${data[tree.props.children]?.length})`
|
}}
|
</el-text>
|
</slot>
|
</div>
|
</template>
|
</el-tree>
|
</template>
|
|
<!-- 列表类型 -->
|
<template v-else>
|
<ul
|
v-infinite-scroll="onMore"
|
class="list"
|
:infinite-scroll-immediate="false"
|
:infinite-scroll-disabled="loaded"
|
>
|
<li
|
v-for="(item, index) in list"
|
:key="index"
|
@click="select(item)"
|
@contextmenu="
|
e => {
|
onContextMenu(e, item);
|
}
|
"
|
>
|
<slot
|
name="item"
|
:item="item"
|
:selected="selected"
|
:index="index"
|
>
|
<div
|
class="item"
|
:class="{
|
'is-active': selected?.id == item.id
|
}"
|
>
|
<slot
|
name="item-name"
|
:item="item"
|
:selected="selected"
|
:index="index"
|
>
|
<span
|
class="text-ellipsis overflow-hidden mr-2"
|
>
|
{{ item.name }}
|
</span>
|
</slot>
|
|
<cl-svg
|
name="right"
|
class="ml-auto"
|
v-show="selected?.id == item.id"
|
/>
|
</div>
|
</slot>
|
</li>
|
|
<el-empty v-if="list.length == 0" :image-size="80" />
|
</ul>
|
</template>
|
</el-scrollbar>
|
</div>
|
</div>
|
</slot>
|
|
<!-- 收起按钮 -->
|
<div v-if="browser.isMini" class="collapse-btn" @click="expand(false)">
|
<cl-svg name="right" />
|
</div>
|
</div>
|
|
<!-- 右侧 -->
|
<div class="cl-view-group__right">
|
<div class="head">
|
<div
|
class="icon is-bg absolute left-[10px]"
|
:class="{ 'is-fold': !isExpand }"
|
@click="expand()"
|
>
|
<cl-svg name="back" />
|
</div>
|
|
<slot name="title" :selected="selected">
|
<span class="title">
|
{{ config.title }}({{ selected?.name || $t('未选择') }})
|
</span>
|
</slot>
|
|
<div class="absolute right-[10px]">
|
<slot name="right-op"></slot>
|
</div>
|
</div>
|
|
<div v-if="selected || config.custom" class="content">
|
<slot name="right"></slot>
|
</div>
|
|
<el-empty v-else :image-size="80" />
|
</div>
|
</div>
|
|
<cl-form ref="Form" />
|
</div>
|
</template>
|
|
<script lang="ts" setup>
|
defineOptions({
|
name: 'cl-view-group'
|
});
|
|
import { useI18n } from 'vue-i18n';
|
import { inject, nextTick, onMounted, reactive, ref, useSlots } from 'vue';
|
import { Search } from '@element-plus/icons-vue';
|
import { useBrowser, useCool } from '/@/cool';
|
import { ContextMenu, useForm } from '@cool-vue/crud';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
import { assign, isEmpty, merge } from 'lodash-es';
|
import { deepTree } from '/@/cool/utils';
|
|
const { browser, onScreenChange } = useBrowser();
|
const slots = useSlots();
|
const Form = useForm();
|
const { refs, setRefs } = useCool();
|
const { t } = useI18n();
|
|
// 配置
|
const config = reactive(
|
assign(
|
{
|
label: t('组'),
|
title: t('列表'),
|
leftWidth: '300px',
|
data: {},
|
service: {},
|
enableContextMenu: true,
|
enableRefresh: true,
|
enableKeySearch: true,
|
enableAdd: true,
|
custom: false
|
},
|
inject('useViewGroup__options')
|
)
|
) as ClViewGroup.Options;
|
|
// 左侧内容是否自定义
|
const isCustom = !!slots.left;
|
|
if (isEmpty(config.service) && !isCustom) {
|
console.error('[cl-view-group] service is required');
|
}
|
|
// 加载中
|
const loading = ref(false);
|
|
// 搜索关键字
|
const keyWord = ref('');
|
|
// 列表
|
const list = ref<ClViewGroup.Item[]>([]);
|
|
// 是否展开
|
const isExpand = ref(true);
|
|
// 选中值
|
const selected = ref<ClViewGroup.Item>();
|
|
// 树配置
|
const tree = reactive(
|
merge(
|
{
|
visible: !!config.tree,
|
props: {
|
label: 'name',
|
children: 'children',
|
disabled: 'disabled',
|
isLeaf: 'isLeaf',
|
class: ''
|
},
|
defaultExpandedKeys: [] as number[]
|
},
|
config.tree
|
)
|
);
|
|
// 收起、展开
|
function expand(value?: boolean) {
|
isExpand.value = value === undefined ? !isExpand.value : value;
|
}
|
|
// 设置选中值
|
function select(data?: ClViewGroup.Item) {
|
if (!data) {
|
data = list.value[0];
|
}
|
|
selected.value = data;
|
|
nextTick(() => {
|
if (data) {
|
if (browser.isMini) {
|
expand(false);
|
}
|
|
if (config.onSelect) {
|
config.onSelect(data);
|
}
|
}
|
});
|
}
|
|
// 编辑
|
function edit(item?: ClViewGroup.Item) {
|
Form.value?.open({
|
title: (item ? t('编辑') : t('添加')) + config.label,
|
form: {
|
...item
|
},
|
on: {
|
submit(data, { close, done }) {
|
config.service[item ? 'update' : 'add'](data)
|
.then(() => {
|
ElMessage.success(t('保存成功'));
|
|
if (item) {
|
assign(item, data);
|
}
|
|
refresh();
|
close();
|
})
|
.catch(err => {
|
ElMessage.error(err.message);
|
done();
|
});
|
}
|
},
|
...(config.onEdit?.(item) as any)
|
});
|
}
|
|
// 删除
|
function remove(item: ClViewGroup.Item) {
|
ElMessageBox.confirm(t('此操作将会删除选择的数据,是否继续?'), t('提示'), {
|
type: 'warning'
|
})
|
.then(() => {
|
function next(params: any) {
|
config.service
|
.delete(params)
|
.then(async () => {
|
ElMessage.success(t('删除成功'));
|
|
// 刷新列表
|
await refresh();
|
|
// 删除当前
|
if (selected.value?.id == item.id) {
|
select();
|
}
|
})
|
.catch(err => {
|
ElMessage.error(err.message);
|
});
|
}
|
|
// 删除事件
|
if (config.onDelete) {
|
config.onDelete(item, { next });
|
} else {
|
next({ ids: [item.id] });
|
}
|
})
|
.catch(() => null);
|
}
|
|
// 请求参数
|
const reqParams = {
|
order: 'createTime',
|
sort: 'asc',
|
page: 1,
|
size: 50 // 每页条数
|
};
|
|
// 是否加载完
|
const loaded = ref(false);
|
|
// 刷新
|
async function refresh(params?: any) {
|
if (isCustom) {
|
return false;
|
}
|
|
assign(reqParams, params);
|
|
loading.value = true;
|
|
const data = {
|
...reqParams,
|
...config.data,
|
keyWord: keyWord.value
|
};
|
|
let req: Promise<void>;
|
|
if (tree.visible) {
|
// 树形数据
|
req = config.service.list(data).then(res => {
|
list.value = deepTree(res);
|
});
|
} else {
|
// 列表数据
|
req = config.service.page(data).then(res => {
|
const arr = config.onData?.(res.list) || res.list;
|
|
if (reqParams.page == 1) {
|
list.value = arr;
|
} else {
|
list.value.push(...arr);
|
}
|
|
loaded.value = res.pagination.total <= list.value.length;
|
});
|
}
|
|
await req
|
.then(() => {
|
const item = selected.value || list.value[0];
|
|
if (item) {
|
if (tree.visible) {
|
const node = refs.tree.getNode(item);
|
node.expand();
|
}
|
|
select(item);
|
}
|
})
|
.catch(err => {
|
ElMessage.error(err.message);
|
});
|
|
loading.value = false;
|
}
|
|
// 加载更多
|
function onMore() {
|
refresh({
|
page: reqParams.page + 1
|
});
|
}
|
|
// 右键菜单
|
function onContextMenu(e: any, item: ClViewGroup.Item) {
|
if (!config.enableContextMenu) {
|
return false;
|
}
|
|
ContextMenu.open(e, {
|
hover: {
|
target: 'item'
|
},
|
list: [
|
{
|
label: t('编辑'),
|
hidden: !config.service._permission?.update,
|
callback(done) {
|
done();
|
edit(item);
|
}
|
},
|
{
|
label: t('删除'),
|
hidden: !config.service._permission?.delete,
|
callback(done) {
|
done();
|
remove(item);
|
}
|
}
|
],
|
...(config.onContextMenu && config.onContextMenu(item))
|
});
|
}
|
|
// 监听屏幕变化
|
onScreenChange(() => {
|
expand(!browser.isMini);
|
});
|
|
onMounted(() => {
|
refresh();
|
});
|
|
defineExpose({
|
list,
|
selected,
|
isExpand,
|
expand,
|
select,
|
browser,
|
edit,
|
remove,
|
refresh
|
});
|
</script>
|
|
<style lang="scss" scoped>
|
.cl-view-group {
|
height: 100%;
|
width: 100%;
|
|
$left-width: v-bind('config.leftWidth');
|
$bg: var(--el-bg-color);
|
|
:deep(.icon) {
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
cursor: pointer;
|
height: 26px;
|
width: 26px;
|
font-size: 16px;
|
border-radius: 6px;
|
color: var(--el-text-color-regular);
|
|
&:hover {
|
background-color: var(--el-fill-color-light);
|
color: var(--el-text-color-primary);
|
}
|
|
&.is-bg {
|
background-color: var(--el-fill-color-lighter);
|
}
|
|
&.is-fold {
|
transform: rotate(180deg);
|
}
|
}
|
|
&__wrap {
|
display: flex;
|
height: 100%;
|
width: 100%;
|
position: relative;
|
}
|
|
&__left {
|
position: relative;
|
height: 100%;
|
width: $left-width;
|
background-color: $bg;
|
|
.collapse-btn {
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
position: absolute;
|
right: 20px;
|
bottom: 30px;
|
height: 40px;
|
width: 40px;
|
border-radius: 100%;
|
background-color: var(--el-color-primary);
|
box-shadow: 0 0 10px 1px rgba(0, 0, 0, 0.3);
|
|
.cl-svg {
|
color: #fff;
|
font-size: 18px;
|
}
|
}
|
|
.scope {
|
display: flex;
|
flex-direction: column;
|
height: 100%;
|
width: 100%;
|
box-sizing: border-box;
|
white-space: nowrap;
|
|
.head {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
height: 40px;
|
font-size: 14px;
|
padding: 0 10px;
|
border-bottom: 1px solid var(--el-border-color-extra-light);
|
|
.label {
|
flex: 1;
|
}
|
|
.icon {
|
margin-left: 5px;
|
}
|
}
|
|
.search {
|
padding: 10px 10px 0 10px;
|
|
:deep(.el-input) {
|
.el-input__wrapper {
|
border-radius: 6px;
|
}
|
}
|
}
|
|
.data {
|
flex: 1;
|
overflow: hidden;
|
box-sizing: border-box;
|
margin-top: 10px;
|
|
:deep(.el-tree-node__content) {
|
height: 36px;
|
margin: 0 5px;
|
}
|
|
.tree {
|
.item {
|
display: flex;
|
align-items: center;
|
flex: 1;
|
overflow: hidden;
|
}
|
}
|
|
.list {
|
height: 100%;
|
|
li {
|
.item {
|
display: flex;
|
align-items: center;
|
list-style: none;
|
box-sizing: border-box;
|
padding: 10px 12px;
|
margin: 0 10px;
|
cursor: pointer;
|
font-size: 14px;
|
margin-bottom: 10px;
|
border-radius: 6px;
|
color: var(--el-text-color-regular);
|
position: relative;
|
background-color: var(--el-fill-color-lighter);
|
|
&.is-active {
|
background-color: var(--el-color-primary);
|
color: #fff;
|
}
|
|
&:hover:not(.is-active) {
|
background-color: var(--el-fill-color-light);
|
}
|
}
|
}
|
|
&::after {
|
display: block;
|
content: '';
|
height: 1px;
|
}
|
}
|
}
|
}
|
}
|
|
&__right {
|
display: flex;
|
flex-direction: column;
|
position: absolute;
|
right: 0;
|
top: 0;
|
height: 100%;
|
width: 100%;
|
transition: width 0.3s;
|
background-color: $bg;
|
|
.head {
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
height: 40px;
|
position: relative;
|
font-size: 14px;
|
border-bottom: 1px solid var(--el-border-color-extra-light);
|
|
.title {
|
white-space: nowrap;
|
overflow: hidden;
|
}
|
}
|
|
.content {
|
height: calc(100% - 40px);
|
}
|
}
|
|
&.is-expand {
|
.cl-view-group__right {
|
width: calc(100% - $left-width);
|
border-left: 1px solid var(--el-border-color-extra-light);
|
}
|
}
|
|
@media only screen and (max-width: 768px) {
|
.cl-view-group__left {
|
overflow: hidden;
|
transition: width 0.2s;
|
width: 0;
|
z-index: 20;
|
}
|
|
.cl-view-group__right {
|
width: 100% !important;
|
}
|
|
&.is-expand {
|
.cl-view-group__left {
|
width: 100%;
|
}
|
}
|
}
|
}
|
</style>
|