feat: add canvas route bar (#967)

* feat: add route link bar above the canvas

* feat: route bar support switch route

* add TODO

* change css variables

* fix: fix switch page error after create one

* feat: support select router page on RouterLink

* feat: RouterLink support navigate by right-click menu operation in canvas

* feat: navigation operation place to first

* feat: support setting default page

* fix: hide route bar in block canvas

* fix: hide route bar in block canvas

* feat: add confirm modal before switch page

* fix: remove TODO comment

* fix: Modified based on review comments

* fix some issues

* fix wrong type of route id
This commit is contained in:
Gene 2025-01-07 16:46:27 +08:00 committed by GitHub
parent 170fd41b9c
commit 3bf93ffb55
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 489 additions and 100 deletions

View File

@ -1,5 +1,8 @@
<template>
<component :is="CanvasLayout">
<template #header>
<component v-if="!isBlock()" :is="CanvasRouteBar"></component>
</template>
<template #container>
<component
:is="CanvasContainer.entry"
@ -52,7 +55,7 @@ export default {
setup() {
const registry = getMergeRegistry('canvas')
const materialsPanel = getMergeMeta('engine.plugins.materials')?.entry
const { CanvasBreadcrumb } = registry.components
const { CanvasRouteBar, CanvasBreadcrumb } = registry.components
const CanvasLayout = registry.layout.entry
const [CanvasContainer] = registry.metas
const footData = ref([])
@ -74,6 +77,8 @@ export default {
pageState.properties = null
}
const isBlock = useCanvas().isBlock
watch(
[() => useCanvas().isSaved(), () => useLayout().layoutState.pageStatus, () => useCanvas().getPageSchema()],
([isSaved, pageStatus, pageSchema], [oldIsSaved, _oldPageStatus, oldPageSchema]) => {
@ -221,9 +226,11 @@ export default {
addHistoryDataChangedCallback,
ast
},
isBlock,
CanvasLayout,
canvasRef,
CanvasContainer,
CanvasRouteBar,
CanvasBreadcrumb
}
}

View File

@ -2,7 +2,7 @@
<div v-show="menuState.show" ref="menuDom" class="context-menu" :style="menuState.position">
<ul class="menu-item">
<li
v-for="(item, index) in menus"
v-for="(item, index) in filteredMenus"
:key="index"
:class="{
'li-item': item.items,
@ -32,9 +32,9 @@
</template>
<script lang="jsx">
import { ref, reactive, nextTick } from 'vue'
import { ref, reactive, nextTick, computed } from 'vue'
import { canvasState, getConfigure, getController, getCurrent, copyNode, removeNodeById } from '../container'
import { useLayout, useModal, useCanvas, getMergeMeta } from '@opentiny/tiny-engine-meta-register'
import { useLayout, useModal, useCanvas, usePage, getMergeMeta } from '@opentiny/tiny-engine-meta-register'
import { iconRight } from '@opentiny/vue-icon'
const menuState = reactive({
@ -121,6 +121,25 @@ export default {
menus.value.push({ name: '新建区块', code: 'createBlock' })
}
menus.value.unshift({
name: '路由跳转',
code: 'route',
show: () => getCurrent()?.schema?.componentName === 'RouterLink',
check: () => {
const targetPageId = getCurrent().schema.props?.to?.name
return typeof targetPageId === 'number' || targetPageId
}
})
const filteredMenus = computed(() =>
menus.value.filter((item) => {
if (typeof item.show === 'function') {
return item.show()
}
return true
})
)
const boxVisibility = ref(false)
//
@ -205,10 +224,19 @@ export default {
status: 'error'
})
}
},
route() {
// check targetPageId
const targetPageId = getCurrent().schema.props.to.name
usePage().switchPageWithConfirm(targetPageId)
}
}
const actionDisabled = (actionItem) => {
if (typeof actionItem.check === 'function' && !actionItem.check()) {
return true
}
const actions = ['del', 'copy', 'addParent']
return actions.includes(actionItem.code) && !getCurrent().schema?.id
}
@ -221,7 +249,7 @@ export default {
boxVisibility.value = false
}
const doOperation = (item) => {
if ((item.check && !item.check?.()) || actionDisabled(item)) {
if (actionDisabled(item)) {
return
}
@ -234,7 +262,7 @@ export default {
return {
SaveNewBlock,
menuState,
menus,
filteredMenus,
doOperation,
boxVisibility,
close,

View File

@ -9,6 +9,7 @@
* A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS.
*
*/
import { CanvasRouteBar } from './route-bar'
import { CanvasBreadcrumb } from './breadcrumb'
// meta app
@ -21,6 +22,7 @@ export { CanvasContainer, CanvasLayout, DesignCanvas }
export default {
...DesignCanvas,
components: {
CanvasRouteBar,
CanvasBreadcrumb
},
layout: CanvasLayout,

View File

@ -1,5 +1,6 @@
<template>
<div id="canvas-wrap" ref="canvasRef">
<slot name="header"></slot>
<div ref="siteCanvas" class="site-canvas" :style="siteCanvasStyle">
<slot name="container"></slot>
</div>
@ -8,13 +9,20 @@
</template>
<script setup>
import { computed } from 'vue'
import { useLayout } from '@opentiny/tiny-engine-meta-register'
import { useCanvas, useLayout } from '@opentiny/tiny-engine-meta-register'
const ROUTE_BAR_HEIGHT = 32
const { isBlock } = useCanvas()
const dimension = useLayout().getDimension()
const siteCanvasStyle = computed(() => {
const { scale } = useLayout().getDimension()
const { scale } = dimension
const routeBarHeight = isBlock() ? 0 : ROUTE_BAR_HEIGHT
return {
height: `calc((100% - var(--base-bottom-panel-height, 30px) - 36px) / ${scale})`,
transform: `scale(${scale})`
height: `calc((100% - var(--base-bottom-panel-height, 30px) - ${36 + routeBarHeight}px) / ${scale})`,
transform: `scale(${scale})`,
marginTop: `${18 + routeBarHeight}px`
}
})
</script>
@ -32,7 +40,7 @@ const siteCanvasStyle = computed(() => {
background: var(--ti-lowcode-breadcrumb-hover-bg);
position: absolute;
overflow: hidden;
margin: 18px 0;
margin-bottom: 18px;
transform-origin: top;
}
}

View File

@ -10,7 +10,7 @@
</span>
</template>
<script lang="ts">
import { computed, inject } from 'vue'
import { computed, inject, PropType, Ref } from 'vue'
export default {
props: {
activeClass: {
@ -22,17 +22,30 @@ export default {
default: ''
},
to: {
type: String
// TODO:
// type: Object as PropType<{
// pageId: string
// }>
// TODO: String
type: Object as PropType<{
name: string
}>
}
},
setup(props) {
const pageAncestor = (inject('page-ancestors') as Ref<string[] | null>).value
const active = computed(() => pageAncestor?.length && pageAncestor.indexOf(props.to) > -1)
const exactActive = computed(() => pageAncestor?.length && props.to === pageAncestor[pageAncestor.length - 1])
const active = computed(() => {
if (!Array.isArray(pageAncestor) || !props.to?.name) {
return false
}
return pageAncestor.includes(props.to.name)
})
const exactActive = computed(() => {
if (!Array.isArray(pageAncestor) || !props.to?.name) {
return false
}
return props.to.name === pageAncestor[pageAncestor.length - 1]
})
return {
active,
exactActive

View File

@ -180,7 +180,7 @@
},
"cols": 12,
"widget": {
"component": "InputConfigurator",
"component": "RouterSelectConfigurator",
"props": {}
}
},
@ -630,9 +630,7 @@
"icon": "RouterLink",
"schema": {
"componentName": "RouterLink",
"props": {
"to": ""
},
"props": {},
"children": [
{
"componentName": "Text",

View File

@ -0,0 +1 @@
export { default as CanvasRouteBar } from './src/CanvasRouteBar.vue'

View File

@ -0,0 +1,121 @@
<template>
<div id="canvas-route-bar" :style="sizeStyle">
<div class="address-bar">
<template v-for="route in routes" :key="route.id">
<span class="slash">/</span>
<span :class="[{ route: route.isPage && route.id !== pageId }]" @click="handleClickRoute(route)">{{
route.route
}}</span>
</template>
</div>
</div>
</template>
<script setup>
import { getMetaApi, META_SERVICE, useLayout, useMessage, usePage } from '@opentiny/tiny-engine-meta-register'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
const sizeStyle = computed(() => {
const { width } = useLayout().getDimension()
return { width }
})
const { pageSettingState, getAncestors, switchPageWithConfirm } = usePage()
const pageId = ref(getMetaApi(META_SERVICE.GlobalService).getBaseInfo().pageId)
const { subscribe, unsubscribe } = useMessage()
let subscriber = null
onMounted(() => {
subscriber = subscribe({
topic: 'locationHistoryChanged',
callback: (data) => {
if (data.pageId) {
pageId.value = data.pageId
}
},
subscriber: 'routeBar'
})
})
onUnmounted(() => {
if (subscriber) {
unsubscribe(subscriber)
}
})
/**
* @typedef {Object} Route
* @property {string | number} id
* @property {string} route
* @property {boolean} isPage
*/
/** @type {import('vue').Ref<Route[]>} */
const routes = ref([])
watch(
pageId,
async (value) => {
if (!value) {
routes.value = []
return
}
const ancestors = await getAncestors(value, true)
routes.value = ancestors.concat(value).map((id) => {
const { route, isPage } = pageSettingState.treeDataMapping[id]
return {
id,
route: route
.replace(/\/+/g, '/') // 替换连续的 '/' 为单个 '/'
.replace(/^\/|\/$/g, ''), // '/'
isPage
}
})
},
{ immediate: true }
)
/**
* @param route {Route}
*/
const handleClickRoute = (route) => {
switchPageWithConfirm(route.id)
}
</script>
<style lang="less" scoped>
#canvas-route-bar {
position: absolute;
top: 18px;
height: 32px;
max-width: 100%;
background-color: var(--te-common-bg-prompt);
border-top-left-radius: 4px;
border-top-right-radius: 4px;
display: flex;
align-items: center;
padding: 0 8px;
}
.address-bar {
display: flex;
align-items: center;
gap: 2px;
background-color: var(--te-common-bg-container);
height: 20px;
width: 100%;
border-radius: 999px;
padding: 0 10px;
cursor: default;
}
.route {
cursor: pointer;
&:hover {
text-decoration: underline;
color: var(--te-common-text-link);
}
}
</style>

View File

@ -20,6 +20,7 @@ import RadioConfigurator from './radio-configurator/RadioConfigurator.vue'
import RadioGroupConfigurator from './radio-group-configurator/RadioGroupConfigurator.vue'
import RelatedColumnsConfigurator from './related-columns-configurator/RelatedColumnsConfigurator.vue'
import RelatedEditorConfigurator from './related-editor-configurator/RelatedEditorConfigurator.vue'
import RouterSelectConfigurator from './router-select-configurator/RouterSelectConfigurator.vue'
import SelectConfigurator from './select-configurator/SelectConfigurator.vue'
import SelectIconConfigurator from './select-icon-configurator/SelectIconConfigurator.vue'
import SliderConfigurator from './slider-configurator/SliderConfigurator.vue'
@ -54,6 +55,7 @@ export {
RadioGroupConfigurator,
RelatedColumnsConfigurator,
RelatedEditorConfigurator,
RouterSelectConfigurator,
SelectConfigurator,
SelectIconConfigurator,
SliderConfigurator,

View File

@ -0,0 +1,138 @@
<template>
<tiny-select
v-model="state.selected"
value-field="id"
render-type="tree"
:tree-op="treeFolderOp"
text-field="name"
:clearable="true"
popper-class="page-tree-select-dropdown"
@change="handleChange"
>
</tiny-select>
</template>
<script setup lang="jsx">
import { usePage } from '@opentiny/tiny-engine-meta-register'
import { Select as TinySelect } from '@opentiny/vue'
import { computed, defineEmits, defineProps, reactive, watch } from 'vue'
const props = defineProps({
modelValue: {
type: [String, Array],
default: () => ''
}
})
const emit = defineEmits(['update:modelValue'])
const state = reactive({
selected: props.modelValue?.name ?? ''
})
watch(
() => props.modelValue?.name,
(value) => {
state.selected = value ?? ''
}
)
const { pageSettingState, getPageList, STATIC_PAGE_GROUP_ID } = usePage()
const pages = computed(() => pageSettingState.pages[STATIC_PAGE_GROUP_ID].data)
if (!Array.isArray(pages.value)) {
getPageList()
}
const pageToTreeData = (page) => {
const { id, name, isPage, children } = page
// id
const result = { id: String(id), name, isPage, disabled: !isPage }
if (Array.isArray(children)) {
result.children = children.map((page) => pageToTreeData(page))
}
return result
}
const getNodeIcon = (data) => {
if (data.id === pageSettingState.ROOT_ID) {
return null
}
if (data.isPage) {
return <SvgIcon name="text-page-common"></SvgIcon>
}
return <SvgIcon name="text-page-folder"></SvgIcon>
}
const treeFolderOp = computed(() => {
const dummyRoot = pageToTreeData({ children: pages.value })
const data = dummyRoot.children
const options = {
data: data,
shrinkIcon: null,
expandIcon: null,
renderContent: (_h, { node, data }) => {
return (
<>
{getNodeIcon(data)}
<div>{node.label}</div>
</>
)
}
}
return options
})
const handleChange = () => {
emit('update:modelValue', { name: state.selected })
}
</script>
<style lang="less">
.tiny-select-dropdown.page-tree-select-dropdown {
padding: 8px 0;
.tiny-tree .tiny-tree-node__wrapper .tiny-tree-node {
.tiny-tree-node__content {
padding: 0;
background-color: var(--te-common-bg-default);
&:hover {
background-color: var(--te-common-bg-container);
}
// hover.tiny-tree-node__contenthover
.tiny-tree-node__content-left,
.tiny-tree-node__content-left .tiny-tree-node__content-box {
background-color: unset;
&:hover {
background-color: unset;
}
}
.tiny-tree-node__content-left {
padding: 0;
.tree-node-icon {
margin: 0;
}
.tiny-tree-node__content-box {
padding: 0 12px;
svg {
margin-right: 8px;
}
}
.tiny-tree-node__label {
font-size: 12px;
}
}
}
&.is-disabled > .tiny-tree-node__content .tiny-tree-node__content-box {
color: var(--te-common-text-disabled);
}
}
}
</style>

View File

@ -53,6 +53,10 @@
</span>
</div>
</tiny-form-item>
<tiny-form-item v-if="pageSettingState.currentPageData.group !== 'publicPages'" prop="isDefault">
<tiny-checkbox v-model="pageSettingState.currentPageData.isDefault">设为默认页</tiny-checkbox>
</tiny-form-item>
</tiny-form>
<page-home
v-if="!isFolder && !pageSettingState.isNew && pageSettingState.currentPageData.group !== 'public'"
@ -62,7 +66,7 @@
<script lang="jsx">
import { ref, computed, watchEffect } from 'vue'
import { Form, FormItem, Input, Select, Radio } from '@opentiny/vue'
import { Form, FormItem, Input, Select, Radio, Checkbox } from '@opentiny/vue'
import { usePage } from '@opentiny/tiny-engine-meta-register'
import { REGEXP_PAGE_NAME, REGEXP_FOLDER_NAME, REGEXP_ROUTE } from '@opentiny/tiny-engine-common/js/verification'
import PageHome from './PageHome.vue'
@ -74,7 +78,8 @@ export default {
TinyInput: Input,
TinySelect: Select,
PageHome,
TinyRadio: Radio
TinyRadio: Radio,
TinyCheckbox: Checkbox
},
props: {
modelValue: {
@ -199,7 +204,7 @@ export default {
return (
<>
{getNodeIcon(data)}
<label>{node.label}</label>
<div>{node.label}</div>
</>
)
}
@ -319,9 +324,6 @@ export default {
svg {
margin-right: 8px;
}
* {
cursor: pointer;
}
}
.tiny-tree-node__label {
font-size: 12px;

View File

@ -55,15 +55,13 @@
</template>
<script lang="jsx">
import { reactive, onUnmounted } from 'vue'
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,
useBreadcrumb,
useLayout,
useNotify,
useMessage,
getMetaApi,
@ -78,7 +76,7 @@ import { closeFolderSettingPanel } from './PageFolderSetting.vue'
import http from './http.js'
import DraggbleTree from './Tree.vue'
const { PAGE_STATUS, COMPONENT_NAME } = constants
const { PAGE_STATUS } = constants
export default {
components: {
@ -98,7 +96,7 @@ export default {
emits: ['openSettingPanel', 'add', 'createPage', 'createFolder'],
setup(props, { emit }) {
const { confirm } = useModal()
const { initData, pageState, isBlock, isSaved } = useCanvas()
const { pageState, isBlock, isSaved } = useCanvas()
const {
pageSettingState,
changeTreeData,
@ -107,10 +105,9 @@ export default {
resetPageData,
STATIC_PAGE_GROUP_ID,
COMMON_PAGE_GROUP_ID,
postLocationHistoryChanged
switchPage: switchPageById
} = usePage()
const { fetchPageDetail, requestUpdatePage } = http
const { setBreadcrumbPage } = useBreadcrumb()
const getAppId = () => getMetaApi(META_SERVICE.GlobalService).getBaseInfo().id
const state = reactive({
@ -119,6 +116,28 @@ export default {
currentNodeData: { id: getMetaApi(META_SERVICE.GlobalService).getBaseInfo().pageId }
})
const { subscribe, unsubscribe } = useMessage()
let subscriber = null
onMounted(() => {
subscriber = subscribe({
topic: 'locationHistoryChanged',
callback: (data) => {
if (data.pageId) {
state.currentNodeData = { id: data.pageId }
}
},
subscriber: 'routeBar'
})
})
onUnmounted(() => {
if (subscriber) {
unsubscribe(subscriber)
}
})
const refreshPageList = async (appId) => {
const pages = await getPageList(appId)
@ -130,58 +149,13 @@ export default {
return pageList
}
const clearCurrentState = () => {
pageState.currentVm = null
pageState.hoverVm = null
pageState.properties = {}
pageState.pageSchema = null
}
const updateUrlPageId = (id) => {
const url = new URL(window.location)
url.searchParams.delete('blockid')
url.searchParams.set('pageid', id)
window.history.pushState({}, '', url)
postLocationHistoryChanged({ pageId: id })
}
const getPageDetail = (pageId) => {
// pageId !== 0 pageId 0
if (pageId !== 0 && !pageId) {
updateUrlPageId('')
initData({ componentName: COMPONENT_NAME.Page }, {})
useLayout().layoutState.pageStatus = {
state: 'empty',
data: {}
}
return
}
fetchPageDetail(pageId).then((data) => {
updateUrlPageId(pageId)
closePageSettingPanel()
closeFolderSettingPanel()
useLayout().closePlugin()
useLayout().layoutState.pageStatus = getCanvasStatus(data.occupier)
initData(data['page_content'], data)
})
}
const switchPage = (data) => {
pageState.hoverVm = null
state.currentNodeData = data
let pageName = ''
if (data.isPage) {
pageName = data?.name || ''
}
setBreadcrumbPage([pageName])
//
clearCurrentState()
getPageDetail(data.id)
switchPageById(data.id).then(() => {
closePageSettingPanel()
closeFolderSettingPanel()
})
}
const nodeClick = (e, pageData) => {

View File

@ -13,10 +13,19 @@
import { reactive, ref } from 'vue'
import { extend, isEqual } from '@opentiny/vue-renderless/common/object'
import { constants } from '@opentiny/tiny-engine-utils'
import { getMetaApi, META_SERVICE } from '@opentiny/tiny-engine-meta-register'
import { getCanvasStatus } from '@opentiny/tiny-engine-common/js/canvas'
import {
useCanvas,
useLayout,
useBreadcrumb,
useModal,
useNotify,
getMetaApi,
META_SERVICE
} from '@opentiny/tiny-engine-meta-register'
import http from '../http'
const { ELEMENT_TAG } = constants
const { ELEMENT_TAG, COMPONENT_NAME } = constants
import { useMessage } from '@opentiny/tiny-engine-meta-register'
const { publish } = useMessage()
@ -178,7 +187,7 @@ const generateTree = (data) => {
}
const getPageList = async (appId) => {
const pagesData = await http.fetchPageList(appId)
const pagesData = await http.fetchPageList(appId || getMetaApi(META_SERVICE.GlobalService).getBaseInfo().id)
const firstGroupData = { groupName: '静态页面', groupId: STATIC_PAGE_GROUP_ID, data: [] }
const secondGroupData = { groupName: '公共页面', groupId: COMMON_PAGE_GROUP_ID, data: [] }
@ -213,16 +222,15 @@ const getPageList = async (appId) => {
/**
* @param {string | number} id
* @param {(string | number)[]} ancestors
* @returns {(string | number)[]}
* @returns {any[]}
*/
const getAncestorsRecursively = (id) => {
const pageNode = pageSettingState.treeDataMapping[id]
if (pageNode.id === pageSettingState.ROOT_ID) {
if (id === pageSettingState.ROOT_ID) {
return []
}
const pageNode = pageSettingState.treeDataMapping[id]
return [pageNode].concat(getAncestorsRecursively(pageNode.parentId))
}
@ -233,24 +241,109 @@ const getAncestorsRecursively = (id) => {
*/
const getAncestors = async (id, withFolders) => {
if (pageSettingState.pages.length === 0) {
const appId = getMetaApi(META_SERVICE.GlobalService).getBaseInfo().id
await getPageList(appId)
await getPageList()
}
if (!pageSettingState.treeDataMapping[id]) {
return null
}
const ancestorsWithSelf = getAncestorsRecursively(id)
const ancestors = ancestorsWithSelf.slice(1).reverse()
if (withFolders) {
return ancestors.map((item) => item.id)
const predicate = withFolders ? () => true : (item) => item.isPage
return ancestors.filter(predicate).map((item) => item.id)
}
const clearCurrentState = () => {
const { pageState } = useCanvas()
pageState.currentVm = null
pageState.hoverVm = null
pageState.properties = {}
pageState.pageSchema = null
}
const updateUrlPageId = (id) => {
const url = new URL(window.location)
url.searchParams.delete('blockid')
url.searchParams.set('pageid', id)
window.history.pushState({}, '', url)
postLocationHistoryChanged({ pageId: id })
}
const switchPage = (pageId) => {
// 切换页面时清空 选中节点信息状态
clearCurrentState()
// pageId !== 0 防止 pageId 为 0 的时候判断不出来
if (pageId !== 0 && !pageId) {
updateUrlPageId('')
useCanvas().initData({ componentName: COMPONENT_NAME.Page }, {})
useLayout().layoutState.pageStatus = {
state: 'empty',
data: {}
}
return
}
return ancestors.filter((item) => item.isPage).map((item) => item.id)
return http
.fetchPageDetail(pageId)
.then((data) => {
if (data.isPage) {
// 应该改成让 Breadcrumb 插件去监听变化
useBreadcrumb().setBreadcrumbPage([data.name])
}
updateUrlPageId(pageId)
useLayout().closePlugin()
useLayout().layoutState.pageStatus = getCanvasStatus(data.occupier)
useCanvas().initData(data['page_content'], data)
})
.catch(() => {
useNotify({
type: 'error',
message: '切换页面失败,目标页面不存在'
})
})
}
const switchPageWithConfirm = (pageId) => {
const checkPageSaved = () => {
const { isSaved, isBlock } = useCanvas()
return new Promise((resolve) => {
if (isSaved()) {
resolve(true)
return
}
useModal().confirm({
title: '提示',
message: `${isBlock() ? '区块' : '页面'}尚未保存,是否要继续切换?`,
exec: () => {
resolve(true)
},
cancel: () => {
resolve(false)
}
})
})
}
checkPageSaved().then((proceed) => {
if (proceed) {
switchPage(pageId)
}
})
}
const getFamily = async (id) => {
if (pageSettingState.pages.length === 0) {
const appId = getMetaApi(META_SERVICE.GlobalService).getBaseInfo().id
await getPageList(appId)
await getPageList()
}
return getAncestorsRecursively(id)
@ -273,6 +366,8 @@ export default () => {
isChangePageData,
getPageList,
getAncestors,
switchPage,
switchPageWithConfirm,
getFamily,
STATIC_PAGE_GROUP_ID,
COMMON_PAGE_GROUP_ID