Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions ui/src/assets/empty/no-data.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions ui/src/assets/empty/no-search-results.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions ui/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ declare module 'vue' {
MkDropdown: typeof import('./components/global/mk-dropdown/index.vue')['default']
MkDropdownItem: typeof import('./components/global/mk-dropdown/mk-dropdown-item.vue')['default']
MkDropdownMenu: typeof import('./components/global/mk-dropdown/mk-dropdown-menu.vue')['default']
MkEmpty: typeof import('./components/global/mk-empty/index.vue')['default']
MkFilterableDropdown: typeof import('./components/global/mk-filterable-dropdown/index.vue')['default']
MkIcon: typeof import('./components/global/mk-icon/index.vue')['default']
MkSearchInput: typeof import('./components/global/mk-search-input/index.vue')['default']
Expand All @@ -40,6 +41,7 @@ declare global {
const MkDropdown: typeof import('./components/global/mk-dropdown/index.vue')['default']
const MkDropdownItem: typeof import('./components/global/mk-dropdown/mk-dropdown-item.vue')['default']
const MkDropdownMenu: typeof import('./components/global/mk-dropdown/mk-dropdown-menu.vue')['default']
const MkEmpty: typeof import('./components/global/mk-empty/index.vue')['default']
const MkFilterableDropdown: typeof import('./components/global/mk-filterable-dropdown/index.vue')['default']
const MkIcon: typeof import('./components/global/mk-icon/index.vue')['default']
const MkSearchInput: typeof import('./components/global/mk-search-input/index.vue')['default']
Expand Down
44 changes: 32 additions & 12 deletions ui/src/components/COMPONENT_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,16 @@ src/components/
│ │ └── mk-dropdown-item.vue # 菜单项布局和选中状态
│ ├── mk-drawer/
│ │ └── index.vue # 统一抽屉关闭行为和内容滚动布局
│ ├── mk-empty/
│ │ └── index.vue # 普通无数据与搜索无匹配的统一空状态
│ ├── mk-filterable-dropdown/
│ │ └── index.vue # 带搜索过滤和滚动列表的下拉选择
│ ├── mk-icon/
│ │ └── index.vue # SVG Symbol 与 Element Plus 图标统一入口
│ ├── mk-view-layout/
│ │ ├── index.vue # 路由页面标题、操作区和内容区统一结构
│ │ └── layout-aside.vue # 页面可选左侧栏结构
│ │ ├── layout-aside.vue # 页面可选左侧栏结构
│ │ └── layout-main.ts # 右侧标题标记提取与滚动内容渲染
│ ├── mk-search-input/
│ │ └── index.vue # 带默认搜索图标的输入框
│ ├── mk-status-label/
Expand Down Expand Up @@ -115,6 +118,17 @@ Element Plus 使用 `ElOnlyChild` 处理浮层触发器。`el-tooltip`、`el-pop

## 自动注册组件

### MkEmpty

全局空状态组件,基于 `el-empty` 统一图片和默认文案。`type` 默认为 `default`,展示“暂无数据”;
搜索无匹配时使用 `type="search"`。可通过 `description`、`image`、`image-size` 覆盖默认内容,
其余 Element Plus Empty 属性通过 `$attrs` 透传,默认、`image` 和 `description` 插槽保持可用。

```vue
<MkEmpty />
<MkEmpty type="search" />
```

### MkDialog

全局对话框组件,统一使用 `el-scrollbar` 包裹内容并为默认插槽提供 `p-6` 内边距。默认显示关闭
Expand Down Expand Up @@ -237,27 +251,33 @@ Element Plus Dropdown 属性和事件通过 `$attrs` 传入,并暴露 `handleO

### MkViewLayout

路由页面的通用内容结构,统一提供满高弹性布局、可选左侧栏、右侧标题栏和滚动内容区。标题优先
使用 `title` Prop,未传入时读取当前路由的 `meta.title`。不传 `header` 插槽时自动渲染标题;需要
标题右侧操作或其他自定义结构时,使用 `header` 作用域插槽完整渲染,并从插槽参数取得解析后的
`title`。传入 `aside` 插槽后才会渲染左侧栏;左右结构上方的独立内容放入 `top` 插槽。
`aside` 作用域插槽提供 `title` 和已经包含左侧标题栏样式的 `Header` 动态组件,左侧标题与内容
可以集中写在同一个插槽中。
路由页面的通用内容结构,统一提供满高弹性布局及可选左侧栏。标题优先使用 `title` Prop,未传入
时读取当前路由的 `meta.title`。`aside` 和默认作用域插槽都提供 `title` 与对应区域的 `Header`
标记组件。默认插槽中的 `Header` 会由布局提取到右侧滚动区上方,其余节点统一放入
`el-scrollbar`;页面因此可以在同一个插槽中组织标题、内容和空状态,并只写一次业务状态判断。
传入 `aside` 插槽后才会渲染左侧栏;左右结构上方的独立内容放入 `top` 插槽。页面加载状态通过
`loading` Prop 传入,由组件将 Element Plus Loading 遮罩绑定到整个布局根节点。默认插槽没有
声明作用域参数时会自动显示当前标题;使用 `#default="{ Header }"` 后则由页面通过
`<component :is="Header">` 控制标题,空状态分支不会自动补充标题。

```vue
<MkViewLayout>
<MkViewLayout :loading="loading">
<template #aside="{ title, Header }">
<component :is="Header">
<h4>{{ title }}</h4>
<el-button text type="primary">创建</el-button>
</component>
左侧列表
</template>
<template #header="{ title }">
<h4>{{ title }}</h4>
<el-button type="primary">创建</el-button>
<template #default="{ title, Header }">
<template v-if="selectedItem">
<component :is="Header">
<h4>{{ title }}</h4>
</component>
右侧内容
</template>
<MkEmpty v-else />
</template>
右侧内容
</MkViewLayout>
```

Expand Down
58 changes: 58 additions & 0 deletions ui/src/components/global/mk-empty/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<script setup lang="ts">
import noDataImage from '@/assets/empty/no-data.svg'
import noSearchResultsImage from '@/assets/empty/no-search-results.svg'

defineOptions({ name: 'MkEmpty', inheritAttrs: false })

const props = withDefaults(
defineProps<{
description?: string
image?: string
imageSize?: number
type?: 'default' | 'search' | ''
}>(),
{
imageSize: 125,
type: 'default',
},
)

defineSlots<{
default(): unknown
description(): unknown
image(): unknown
}>()

const emptyState = computed(() => {
if (props.type === 'search') {
return {
description: '没有找到相关内容',
image: noSearchResultsImage,
}
}

return {
description: '暂无数据',
image: noDataImage,
}
})
</script>

<template>
<el-empty
v-bind="$attrs"
:description="props.description ?? emptyState.description"
:image="props.image ?? emptyState.image"
:image-size="props.imageSize"
>
<template v-if="$slots.image" #image>
<slot name="image" />
</template>
<template v-if="$slots.description" #description>
<slot name="description" />
</template>
<template v-if="$slots.default" #default>
<slot />
</template>
</el-empty>
</template>
8 changes: 8 additions & 0 deletions ui/src/components/global/mk-table/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const props = withDefaults(
maxTableHeight?: number
paginationConfig?: PaginationConfig
resizable?: boolean
search?: boolean
}>(),
{
data: () => [],
Expand Down Expand Up @@ -149,7 +150,13 @@ defineExpose({ clearSelection, tableRef })
</script>

<template>
<MkEmpty
v-if="!props.paginationConfig?.total"
:type="search ? 'search' : ''"
class="flex-1"
/>
<div
v-else
ref="tableRootRef"
class="mk-table relative w-full"
@mouseleave="clearResizeHover"
Expand All @@ -167,6 +174,7 @@ defineExpose({ clearSelection, tableRef })
>
<slot />
</el-table>

<div
v-if="props.resizable && resizeHoverStyle"
class="mk-table__resize-hover-indicator"
Expand Down
40 changes: 22 additions & 18 deletions ui/src/components/global/mk-view-layout/index.vue
Original file line number Diff line number Diff line change
@@ -1,27 +1,37 @@
<script setup lang="ts">
import type { FunctionalComponent } from 'vue'
import type { Component, FunctionalComponent } from 'vue'
import { useRoute } from 'vue-router'
import LayoutAside from './layout-aside.vue'
import LayoutMain from './layout-main'

defineOptions({ name: 'MkViewLayout', inheritAttrs: false })

const props = defineProps<{
title?: string
}>()
const props = withDefaults(
defineProps<{
loading?: boolean
title?: string
}>(),
{
loading: false,
},
)

defineSlots<{
aside?: (props: { Header: FunctionalComponent; title: string }) => unknown
default?: () => unknown
header?: (props: { title: string }) => unknown
default?: (props: { Header: Component; title: string }) => unknown
top?: () => unknown
}>()

const route = useRoute()
const title = computed(() => props.title || route.meta.title || '')
const slots = useSlots()
const fallbackTitle = computed(() =>
!slots.default || slots.default.length === 0 ? title.value : '',
)
</script>

<template>
<div class="flex h-full min-h-0 flex-col" v-bind="$attrs">
<div v-loading="props.loading" class="flex h-full min-h-0 flex-col" v-bind="$attrs">
<div v-if="$slots.top" class="shrink-0 border-b px-4 py-3">
<slot name="top" />
</div>
Expand All @@ -33,17 +43,11 @@ const title = computed(() => props.title || route.meta.title || '')
</template>
</LayoutAside>

<main class="flex min-w-0 flex-1 flex-col px-6">
<header class="flex-between shrink-0 py-4">
<slot name="header" :title="title">
<h4>{{ title }}</h4>
</slot>
</header>

<el-scrollbar class="min-h-0 flex-1">
<slot />
</el-scrollbar>
</main>
<LayoutMain :fallback-title="fallbackTitle" :title="title">
<template #default="{ Header, title: mainTitle }">
<slot :Header="Header" :title="mainTitle" />
</template>
</LayoutMain>
</div>
</div>
</template>
95 changes: 95 additions & 0 deletions ui/src/components/global/mk-view-layout/layout-main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import {
Fragment,
createTextVNode,
defineComponent,
h,
isVNode,
type Component,
type Slots,
type SlotsType,
type VNode,
} from 'vue'
import { ElScrollbar } from 'element-plus'

type LayoutMainSlotProps = {
Header: Component
title: string
}

const LayoutMainHeader = defineComponent({
name: 'MkViewLayoutMainHeader',
setup(_, { slots }) {
return () => slots.default?.()
},
})

function flattenSlotNodes(children: unknown): VNode[] {
const childNodes = Array.isArray(children) ? children : [children]

return childNodes.flatMap((child) => {
if (child === undefined || child === null || typeof child === 'boolean') return []
if (!isVNode(child)) return [createTextVNode(String(child))]
if (child.type === Fragment) return flattenSlotNodes(child.children)
return [child]
})
}

function getHeaderNodes(headerNode: VNode) {
const headerSlots = headerNode.children as Slots | null
return flattenSlotNodes(headerSlots?.default?.())
}

export default defineComponent({
name: 'MkViewLayoutMain',
props: {
fallbackTitle: {
default: '',
type: String,
},
title: {
default: '',
type: String,
},
},
slots: Object as SlotsType<{
default?: (props: LayoutMainSlotProps) => VNode[]
}>,
setup(props, { slots }) {
return () => {
const headerNodes: VNode[] = []
const contentNodes: VNode[] = []

flattenSlotNodes(slots.default?.({ Header: LayoutMainHeader, title: props.title })).forEach(
(node) => {
if (node.type === LayoutMainHeader) {
headerNodes.push(...getHeaderNodes(node))
return
}

contentNodes.push(node)
},
)

const resolvedHeaderNodes =
headerNodes.length > 0
? headerNodes
: props.fallbackTitle
? [h('h4', props.fallbackTitle)]
: []

return h('main', { class: 'flex min-w-0 flex-1 flex-col px-6 mb-6' }, [
resolvedHeaderNodes.length
? h('header', { class: 'flex-between shrink-0 py-4' }, resolvedHeaderNodes)
: null,
h(
ElScrollbar,
{
class: 'min-h-0 flex-1',
viewClass: 'flex min-h-full flex-col',
},
{ default: () => contentNodes },
),
])
}
},
})
4 changes: 3 additions & 1 deletion ui/src/components/mk-search-list/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,9 @@ function selectRow(row: T, index: number) {
</template>
</div>
<slot v-else name="empty">
<el-empty :description="emptyText" :image-size="80" />
<p class="text-center mt-20 text-N600">
{{ searchKeyword ? '没有找到相关内容' : emptyText }}
</p>
</slot>
</el-scrollbar>
</div>
Expand Down
Loading
Loading