<template>
<div class="app-manage-search">
<tiny-search v-model="state.pageSearchValue" clearable placeholder="搜索">
<template #prefix>
<tiny-icon-search />
</template>
</tiny-search>
</div>
<tiny-collapse v-model="state.collapseValue" class="page-manage-collapse lowcode-scrollbar">
<tiny-collapse-item v-for="(groupItem, index) in pageSettingState.pages" :key="index" :name="groupItem.groupId">
<template #title>
<span class="title">{{ groupItem.groupName }}</span>
</template>
<div class="app-manage-tree">
<draggable-tree
:data="groupItem.data"
label-key="name"
:active="state.currentNodeData.id"
:filter-value="state.pageSearchValue"
:root-id="pageSettingState.ROOT_ID"
:draggable="groupItem.groupId === STATIC_PAGE_GROUP_ID"
@click-row="handleClickRow"
@move-node="handleMoveNode"
>
<template #row-suffix="{ node }">
<div :class="['actions']">
<svg-button v-if="isPageLocked(node.rawData)" name="locked" :hoverBgColor="false"></svg-button>
<svg-button v-if="node.rawData.isHome" name="home" :hoverBgColor="false"></svg-button>
<tiny-popover
:ref="(el) => setPopoverRef(el, node.id)"
placement="bottom-start"
:visible-arrow="false"
popper-class="page-tree-row-operation-list"
>
<div class="operation-list">
<div
v-for="(operation, index) in getRowOperations(groupItem.groupId, node)"
:key="index"
:class="[operation.type === 'divider' ? 'divider' : 'item'].concat(operation.class || [])"
@click="operation.action?.(node)"
>
{{ operation.label }}
</div>
</div>
<template #reference>
<svg-button name="ellipsis" class="auto-hidden" :hoverBgColor="false"></svg-button>
</template>
</tiny-popover>
</div>
</template>
</draggable-tree>
</div>
</tiny-collapse-item>
</tiny-collapse>
</template>
<script lang="tsx">
/* metaService: engine.plugins.appmanage.PageTree */
import { reactive, onMounted, onUnmounted } from 'vue'
import { Search, Collapse, CollapseItem, Popover } from '@opentiny/vue'
import { IconFolderOpened, IconFolderClosed, IconSearch } from '@opentiny/vue-icon'
import {
useCanvas,
useModal,
usePage,
useNotify,
useMessage,
getMetaApi,
getOptions,
META_SERVICE
} from '@opentiny/tiny-engine-meta-register'
import { isEqual } from '@opentiny/vue-renderless/common/object'
import { getCanvasStatus } from '@opentiny/tiny-engine-common/js/canvas'
import { handlePageUpdate } from '@opentiny/tiny-engine-common/js/http'
import { constants } from '@opentiny/tiny-engine-utils'
import { closePageSettingPanel } from './PageSetting.vue'
import { closeFolderSettingPanel } from './PageFolderSetting.vue'
import http from './http'
import DraggableTree from './Tree.vue'
import { SvgButton } from '@opentiny/tiny-engine-common'
import meta from '../meta'
const { PAGE_STATUS } = constants
export default {
components: {
TinySearch: Search,
TinyCollapse: Collapse,
TinyCollapseItem: CollapseItem,
TinyIconSearch: IconSearch(),
TinyPopover: Popover,
DraggableTree,
SvgButton
},
props: {
isFolder: {
type: Boolean,
default: false
}
},
emits: ['openSettingPanel', 'add', 'createPage', 'createFolder', 'settingHome'],
setup(props, { emit }) {
const { confirm } = useModal()
const { pageState, isBlock, isSaved } = useCanvas()
const {
pageSettingState,
changeTreeData,
isCurrentDataSame,
getPageList,
resetPageData,
STATIC_PAGE_GROUP_ID,
COMMON_PAGE_GROUP_ID,
switchPage: switchPageById
} = usePage()
const { fetchPageDetail, requestUpdatePage } = http
const getAppId = () => getMetaApi(META_SERVICE.GlobalService).getBaseInfo().id
const { customPageOperations = [] } = getOptions(meta.id)
const state = reactive({
pageSearchValue: '',
collapseValue: [STATIC_PAGE_GROUP_ID, COMMON_PAGE_GROUP_ID],
currentNodeData: { id: getMetaApi(META_SERVICE.GlobalService).getBaseInfo().pageId }
})
const { subscribe, unsubscribe } = useMessage()
const refreshPageList = (appId: string) => getPageList(appId)
let subscriber = null
onMounted(() => {
refreshPageList(getAppId())
subscriber = subscribe({
topic: 'locationHistoryChanged',
callback: (data) => {
if (data.pageId) {
state.currentNodeData = { id: data.pageId }
} else if (isBlock()) {
state.currentNodeData = {}
}
},
subscriber: 'pageTree'
})
})
onUnmounted(() => {
if (subscriber) {
unsubscribe(subscriber)
}
})
pageSettingState.updateTreeData = async () => {
const pageList = await refreshPageList(getAppId())
return pageList
}
const switchPage = (data) => {
state.currentNodeData = data
switchPageById(data.id).then(() => {
closePageSettingPanel()
closeFolderSettingPanel()
})
}
const nodeClick = (e, pageData) => {
e?.stopPropagation()
const { id, isPage } = pageData
// 区块切换回页面需要重新加载页面
if ((!isBlock() && id === state?.currentNodeData?.id) || !isPage) {
return
}
if (isSaved() && isCurrentDataSame()) {
switchPage(pageData)
} else {
confirm({
title: '提示',
message: `${isBlock() ? '区块' : '页面'}尚未保存,是否要继续切换?`,
exec: () => {
changeTreeData(pageSettingState.oldParentId, pageSettingState.currentPageData.parentId)
Object.assign(pageSettingState.currentPageData, pageSettingState.currentPageDataCopy)
switchPage(pageData)
}
})
}
}
const openSettingPanel = (e, pageData, isPageLocked) => {
e?.stopPropagation()
if (isPageLocked && pageData.isPage) {
const username = pageData.occupier?.username || ''
useModal().message({
message: `您点击的页面被${username}锁定,暂时无法编辑,请联系解锁`
})
return
}
if (isEqual(pageSettingState.currentPageData, pageSettingState.currentPageDataCopy)) {
emit('openSettingPanel', pageData)
} else {
confirm({
title: '提示',
message: `当前页面或文件夹${pageSettingState.currentPageData.name}尚未保存,是否要继续切换?`,
exec: () => {
changeTreeData(pageSettingState.oldParentId, pageSettingState.currentPageData.parentId)
Object.assign(pageSettingState.currentPageData, pageSettingState.currentPageDataCopy)
emit('openSettingPanel', pageData)
}
})
}
}
const popoverRefs = {}
const setPopoverRef = (el, nodeId) => {
popoverRefs[nodeId] = el
}
const handleClickRow = (node) => {
nodeClick(null, node.rawData)
}
const isPageLocked = (pageData) => {
return getCanvasStatus(pageData.occupier).state === PAGE_STATUS.Lock
}
const handleClickPageSettings = (node) => {
openSettingPanel(null, node.rawData, isPageLocked(node.rawData))
}
const createPage = (node) => {
emit('createPage', 'staticPages', node.id)
}
const createFolder = (node) => {
emit('createFolder', node.id)
}
const settingHome = (node) => {
emit('settingHome', node)
}
const rowOperations = [
{ type: 'settings', label: '设置', action: handleClickPageSettings },
{ type: 'divider' },
{ type: 'createPage', label: '新建子页面', action: createPage },
{ type: 'createFolder', label: '新建子文件夹', action: createFolder },
{ type: 'settingHome', label: '设置为主页', action: settingHome },
...customPageOperations
// TODO 复制和删除的逻辑耦合在其他组件内,暂时屏蔽
// { type: 'divider' },
// { type: 'copy', label: '复制页面', action: copyPage },
// { type: 'delete', label: '删除', class: ['danger'], action: deleteNode }
].map((item) => ({
...item,
action: (node) => {
item.action?.(node, emit)
// 点击 action 后,关闭 popover 弹窗
popoverRefs[node.id]?.doClose?.()
}
}))
const getRowOperations = (groupId, node) => {
if (groupId === COMMON_PAGE_GROUP_ID) {
return rowOperations.slice(0, 1)
}
if (!node.rawData.isPage || node.rawData.group === 'public' || String(node.parentId) !== '0') {
return rowOperations.filter((item) => item.type !== 'settingHome')
}
// TODO 复制逻辑暂时屏蔽
// if (!node.rawData.isPage) {
// return rowOperations.filter((item) => item.type !== 'copy')
// }
return rowOperations
}
const updatePage = (pageDetail) => {
const { id, name, page_content } = pageDetail
const params = {
...pageDetail,
page_content: {
...page_content,
fileName: name
}
}
const isCurEditPage = pageState?.currentPage?.id === id
const updateParams = {
id,
params,
isCurEditPage
}
return handlePageUpdate(updateParams)
}
const updateFolder = (pageDetail) => {
const { id } = pageDetail
// requestUpdatePage加了then和catch回调函数,而handlePageUpdate没有加,是因为handlePageUpdate内部都已经有了类似逻辑
return requestUpdatePage(id, { ...pageDetail, page_content: null })
.then(() => {
useNotify({
type: 'success',
message: '更新文件夹成功!'
})
})
.catch((error) => {
useNotify({
type: 'error',
title: '更新文件夹失败',
message: JSON.stringify(error?.message || error)
})
})
.finally(() => {
pageSettingState.updateTreeData()
pageSettingState.isNew = false
})
}
const handleMoveNode = (dragged, newParent) => {
if (isEqual(pageSettingState.currentPageData, pageSettingState.currentPageDataCopy)) {
closePageSettingPanel()
closeFolderSettingPanel()
pageSettingState.currentPageData.id = dragged.id
changeTreeData(newParent.id, dragged.parentId)
resetPageData()
// TODO 页面更换父节点后,原来每次变更需要填写变更信息
fetchPageDetail(dragged.id)
.then((pageDetail) => {
pageDetail.parentId = newParent.id
if (pageDetail.isPage) {
pageDetail.isDefault = false
updatePage(pageDetail)
} else {
updateFolder(pageDetail)
}
})
.catch((error) => {
useNotify({
type: 'error',
title: '移动页面文件/文件夹失败',
message: JSON.stringify(error?.message || error)
})
})
} else {
confirm({
title: '提示',
message: '更改尚未保存,是否要放弃这些更改?',
exec: () => {
if (!pageSettingState.isNew) {
changeTreeData(pageSettingState.oldParentId, pageSettingState.currentPageData.parentId)
Object.assign(pageSettingState.currentPageData, pageSettingState.currentPageDataCopy)
}
closePageSettingPanel()
}
})
}
}
useMessage().subscribe({
topic: 'app_id_changed',
subscriber: 'page_tree_app_id_changed',
callback: (appId) => {
refreshPageList(appId)
}
})
const createPublicPage = (e) => {
e.stopPropagation()
e.preventDefault()
emit('add')
}
onUnmounted(() => {
useMessage().unsubscribe({
topic: 'app_id_changed',
subscriber: 'page_tree_app_id_changed'
})
})
return {
createPublicPage,
state,
switchPage,
pageSettingState,
setPopoverRef,
IconFolderOpened: IconFolderOpened(),
IconFolderClosed: IconFolderClosed(),
getRowOperations,
handleClickRow,
handleMoveNode,
isPageLocked,
handleClickPageSettings,
STATIC_PAGE_GROUP_ID
}
}
}
</script>
<style lang="less" scoped>
.app-manage-search {
padding: 0 12px 12px 12px;
}
.page-manage-collapse {
height: calc(100% - 95px);
overflow-y: auto;
.app-manage-public-page {
position: absolute;
right: 0;
cursor: pointer;
svg {
font-size: 22px;
}
}
:deep(.tiny-collapse-item__header) {
&,
&.is-active {
&::before {
border: none;
}
}
}
:deep(.tiny-collapse-item) {
border-left: 0;
border-right: 0;
}
:deep(.tiny-collapse-item__content) {
padding: 0 0 12px 0;
}
}
.page-manage-collapse.page-manage-collapse {
:deep(.tiny-collapse-item__content) {
padding-top: 0;
}
}
.app-manage-tree {
.actions {
display: flex;
align-items: center;
svg {
color: var(--te-page-manage-icon-color);
outline: none;
}
.auto-hidden {
visibility: hidden;
}
}
.row:hover .actions .auto-hidden {
visibility: visible;
}
}
</style>
<style lang="less">
.tiny-popover.tiny-popper[x-placement].page-tree-row-operation-list {
padding: 0;
margin-top: 4px;
.operation-list {
min-width: 110px;
padding: 8px 0;
& > div {
padding: 0 12px;
}
.item {
height: 24px;
font-size: 12px;
line-height: 18px;
cursor: pointer;
display: flex;
align-items: center;
&:hover {
background-color: var(--te-page-manage-operation-list-item-bg-color-hover);
}
}
.divider {
height: 8px;
display: flex;
align-items: center;
&::after {
content: '';
display: block;
width: 100%;
height: 1px;
background-color: var(--te-page-manage-operation-list-item-border-color-divider);
}
}
.danger {
color: var(--te-page-manage-operation-list-text-color-error);
}
}
}
</style>