mirror of
https://github.com/cool-team-official/cool-admin-vue.git
synced 2025-12-11 21:12:50 +00:00
693 lines
13 KiB
Vue
693 lines
13 KiB
Vue
<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>
|
||
</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>{{ 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" :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>
|
||
|
||
<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);
|
||
|
||
.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-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;
|
||
|
||
: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;
|
||
}
|
||
|
||
.icon {
|
||
position: absolute;
|
||
left: 10px;
|
||
background-color: var(--el-fill-color-lighter);
|
||
|
||
&:hover {
|
||
background-color: var(--el-fill-color-light);
|
||
}
|
||
}
|
||
}
|
||
|
||
.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: 9;
|
||
}
|
||
|
||
.cl-view-group__right {
|
||
width: 100% !important;
|
||
}
|
||
|
||
&.is-expand {
|
||
.cl-view-group__left {
|
||
width: 100%;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
</style>
|