Commit c3b27a24 authored by Sendya's avatar Sendya

feat: add Header, GlobalHeader, HeaderView

parent 3c53cf5e
import { createApp, defineComponent, reactive } from 'vue';
import 'ant-design-vue/dist/antd.less';
import { createApp, defineComponent, reactive } from 'vue';
import { RouterLink } from './mock-router';
import { Button, message } from 'ant-design-vue';
import { default as ProLayout } from '../src/';
import { menus } from './menus';
......@@ -50,6 +51,11 @@ const BasicLayout = defineComponent({
onSelect={$event => {
$event && (menuState.selectedKeys = $event);
}}
v-slots={{
footerRender: () => (
<div>123</div>
)
}}
>
<Button
onClick={() => {
......@@ -84,4 +90,4 @@ Object.keys(Icon)
app.component(Icon[k].displayName, Icon[k]);
});
app.use(ProLayout).mount('#__vue-content>div');
app.use(RouterLink).use(ProLayout).mount('#__vue-content>div');
import { defineComponent, toRefs, PropType } from 'vue';
import { withInstall } from 'ant-design-vue/es/_util/type';
export const RouterLink = withInstall(defineComponent({
name: 'RouterLink',
props: {
href: {
type: String,
default: null,
},
to: {
type: [Object, String] as PropType<Record<string, any> | string>,
default: () => undefined,
},
},
setup(props, { slots }) {
const { to, href } = toRefs(props);
const curHref = href.value && href.value || typeof to.value === 'string' ? to.value : (to.value.name || to.value.path);
return () => <a href={`#${curHref}`}>{slots.default?.()}</a>;
},
}));
export const RouterView = withInstall(defineComponent({
name: 'RouterView',
setup(_, { slots }) {
return () => slots.default?.();
}
}));
......@@ -7,6 +7,7 @@ import { default as GlobalFooter } from './GlobalFooter';
import { default as SiderMenuWrapper, SiderMenuWrapperProps } from './SiderMenu';
import { WrapContent } from './WrapContent';
import { RenderVNodeType, WithFalse } from './typings';
import { getComponentOrSlot } from './utils';
import './BasicLayout.less';
const defaultI18nRender = (key: string) => key;
......@@ -70,6 +71,25 @@ const ProLayout: FunctionalComponent<ProLayoutProps> = (props, { emit, slots })
[`${baseClassName.value}-${props.layout}`]: props.layout,
};
});
const headerRender = (
props: BasicLayoutProps & {
hasSiderMenu: boolean;
},
matchMenuKeys: string[]
): RenderVNodeType => {
if (props.headerRender === false || props.pure) {
return null;
}
return <Header matchMenuKeys={matchMenuKeys} {...props} />;
}
const footerRender = getComponentOrSlot(props, slots, 'footerRender');
// const headerRender = getComponentOrSlot(props, slots, 'headerRender');
const menuRender = getComponentOrSlot(props, slots, 'menuRender');
const menuHeaderRender = getComponentOrSlot(props, slots, 'menuHeaderRender');
return (
<ProProvider i18n={defaultI18nRender}>
<div class={className.value}>
......@@ -81,44 +101,40 @@ const ProLayout: FunctionalComponent<ProLayoutProps> = (props, { emit, slots })
onCollapse={handleCollapse}
/>
<Layout>
<Layout.Header style="background: #fff; padding: 0; height: 48px; line-height: 48px;"></Layout.Header>
<WrapContent
style={{
margin: '24px 16px',
padding: '24px',
background: '#fff',
minHeight: '280px',
}}
>
<Layout.Header style="background: #fff; padding: 0; height: 48px; line-height: 48px;">
</Layout.Header>
<WrapContent style={props.contentStyle}>
{slots.default?.()}
</WrapContent>
<GlobalFooter
links={[
{
key: '1',
title: 'Pro Layout',
href: 'https://www.github.com/vueComponent/pro-layout',
blankTarget: true,
},
{
key: '2',
title: 'Github',
href: 'https://www.github.com/vueComponent/ant-design-vue-pro',
blankTarget: true,
},
{
key: '3',
title: '@Sendya',
href: 'https://www.github.com/sendya/',
blankTarget: true,
},
]}
copyright={
<a href="https://github.com/vueComponent" target="_blank">
vueComponent
</a>
}
/>
{ footerRender && footerRender || footerRender !== false && (
<GlobalFooter
links={[
{
key: '1',
title: 'Pro Layout',
href: 'https://www.github.com/vueComponent/pro-layout',
blankTarget: true,
},
{
key: '2',
title: 'Github',
href: 'https://www.github.com/vueComponent/ant-design-vue-pro',
blankTarget: true,
},
{
key: '3',
title: '@Sendya',
href: 'https://www.github.com/sendya/',
blankTarget: true,
},
]}
copyright={
<a href="https://github.com/vueComponent" target="_blank">
vueComponent
</a>
}
/>
)}
</Layout>
</Layout>
</div>
......@@ -143,6 +159,12 @@ ProLayout.props = {
selectedKeys: Array,
collapsed: Boolean,
menuData: Array,
contentStyle: Object,
headerRender: [Function, Boolean],
footerRender: [Function, Boolean],
menuRender: [Function, Boolean],
menuHeaderRender: [Function, Boolean],
rightContent: [Function, Boolean],
} as any;
export default withInstall(ProLayout);
@import '~ant-design-vue/es/style/themes/default.less';
@import '../BasicLayout.less';
@pro-layout-global-header-prefix-cls: ~'@{ant-prefix}-pro-global-header';
@pro-layout-header-bg: @component-background;
@pro-layout-header-hover-bg: @component-background;
@pro-layout-header-box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
.@{pro-layout-global-header-prefix-cls} {
position: relative;
display: flex;
align-items: center;
height: 100%;
padding: 0 16px;
background: @pro-layout-header-bg;
box-shadow: @pro-layout-header-box-shadow;
> * {
height: 100%;
}
&-collapsed-button {
display: flex;
align-items: center;
margin-left: 16px;
font-size: 20px;
}
&-layout {
&-mix {
background-color: @layout-sider-background;
.@{pro-layout-global-header-prefix-cls}-logo {
h1 {
color: @btn-primary-color;
}
}
.anticon {
color: @btn-primary-color;
}
}
}
&-logo {
position: relative;
overflow: hidden;
a {
display: flex;
align-items: center;
height: 100%;
img {
height: 28px;
}
h1 {
height: 32px;
margin: 0 0 0 8px;
margin: 0 0 0 12px;
color: @primary-color;
font-weight: 600;
font-size: 18px;
line-height: 32px;
}
}
}
&-menu {
.anticon {
margin-right: 8px;
}
.@{ant-prefix}-dropdown-menu-item {
min-width: 160px;
}
}
.dark {
height: @pro-layout-header-height;
.action {
color: rgba(255, 255, 255, 0.85);
> i {
color: rgba(255, 255, 255, 0.85);
}
&:hover,
&.opened {
background: @primary-color;
}
.@{ant-prefix}-badge {
color: rgba(255, 255, 255, 0.85);
}
}
}
}
import { computed, CSSProperties, FunctionalComponent, Ref } from 'vue';
import { PureSettings } from '../defaultSettings';
import { RenderVNodeType, MenuDataItem, WithFalse } from '../typings';
import { SiderMenuProps, PrivateSiderMenuProps, defaultRenderLogo, defaultRenderLogoAndTitle, defaultRenderCollapsedButton } from '../SiderMenu/SiderMenu';
import { TopNavHeader } from '../TopNavHeader';
import { clearMenuItem } from '../utils';
import type { HeaderViewProps } from '../Header';
import './index.less';
export interface GlobalHeaderProps extends Partial<PureSettings> {
collapsed?: boolean;
onCollapse?: (collapsed: boolean) => void;
isMobile?: boolean;
logo?: RenderVNodeType;
menuRender?: WithFalse<(props: HeaderViewProps, defaultDom: RenderVNodeType) => RenderVNodeType>;
rightContentRender?: WithFalse<(props: HeaderViewProps) => RenderVNodeType>;
className?: string;
prefixCls?: string;
menuData?: MenuDataItem[];
onMenuHeaderClick?: (e: MouseEvent) => void;
style?: CSSProperties;
menuHeaderRender?: SiderMenuProps['menuHeaderRender'];
collapsedButtonRender?: SiderMenuProps['collapsedButtonRender'];
splitMenus?: boolean;
};
const renderLogo = (
menuHeaderRender: SiderMenuProps['menuHeaderRender'],
logoDom: RenderVNodeType,
) => {
if (menuHeaderRender === false) {
return null;
}
if (menuHeaderRender) {
return menuHeaderRender(logoDom, null);
}
return logoDom;
};
export const GlobalHeader: FunctionalComponent<GlobalHeaderProps & PrivateSiderMenuProps> = (props, { slots }) => {
const {
isMobile,
logo,
collapsed,
onCollapse,
collapsedButtonRender = defaultRenderCollapsedButton,
rightContentRender,
menuHeaderRender,
onMenuHeaderClick,
className: propClassName,
style,
layout,
headerTheme = 'dark',
splitMenus,
menuData,
prefixCls,
} = props;
const baseClassName = `${prefixCls}-global-header`;
const className = computed(() => {
return {
[baseClassName]: true,
[`${baseClassName}-layout-${layout}`]: layout && headerTheme === 'dark',
}
});
if (layout === 'mix' && !isMobile && splitMenus) {
const noChildrenMenuData = (menuData || []).map((item) => ({
...item,
children: undefined,
}));
const clearMenuData = clearMenuItem(noChildrenMenuData);
return (
<TopNavHeader
mode="horizontal"
{...props}
splitMenus={false}
menuData={clearMenuData}
theme={headerTheme as 'light' | 'dark'}
/>
);
}
const logoDom = (
<span class={`${baseClassName}-logo`} key="logo">
<a>{defaultRenderLogo(logo)}</a>
</span>
);
return (
<div class={className} style={{ ...style }}>
{isMobile && renderLogo(menuHeaderRender, logoDom)}
{isMobile && collapsedButtonRender && (
<span
class={`${baseClassName}-collapsed-button`}
onClick={() => {
if (onCollapse) {
onCollapse(!collapsed);
}
}}
>
{collapsedButtonRender(collapsed)}
</span>
)}
{layout === 'mix' && !isMobile && (
<>
<div class={`${baseClassName}-logo`} onClick={onMenuHeaderClick}>
{defaultRenderLogoAndTitle({ ...props, collapsed: false }, 'headerTitleRender')}
</div>
</>
)}
<div style={{ flex: 1 }}>{slots.default?.()}</div>
{rightContentRender && rightContentRender(props)}
</div>
);
}
export default GlobalHeader;
@import '~ant-design-vue/es/style/themes/default.less';
@pro-layout-fixed-header-prefix-cls: ~'@{ant-prefix}-pro-fixed-header';
.@{pro-layout-fixed-header-prefix-cls} {
z-index: 9;
width: 100%;
}
import { defineComponent, computed, toRefs, toRaw } from 'vue';
import 'ant-design-vue/es/layout/style';
import Layout from 'ant-design-vue/es/layout';
import { GlobalHeader, GlobalHeaderProps } from './GlobalHeader';
import { TopNavHeader } from './TopNavHeader';
import { RenderVNodeType, WithFalse } from './typings';
import { clearMenuItem } from './utils';
import './Header.less';
const { Header } = Layout;
interface HeaderViewState {
visible: boolean;
}
export type HeaderViewProps = GlobalHeaderProps & {
isMobile?: boolean;
collapsed?: boolean;
logo?: RenderVNodeType;
headerRender?: WithFalse<
(props: HeaderViewProps, defaultDom: RenderVNodeType) => RenderVNodeType
>;
headerTitleRender?: WithFalse<
(props: HeaderViewProps, defaultDom: RenderVNodeType) => RenderVNodeType
>;
headerContentRender?: WithFalse<(props: HeaderViewProps) => RenderVNodeType>;
siderWidth?: number;
hasSiderMenu?: boolean;
};
export const HeaderView = defineComponent<HeaderViewProps>({
setup(props) {
const { prefixCls, headerRender, headerContentRender, isMobile, fixedHeader, hasSiderMenu, headerHeight, layout, navTheme, onCollapse } = toRefs(props);
const isTop = computed(() => props.layout === 'top');
const needFixedHeader = computed(() => fixedHeader.value || layout.value === 'mix');
const needSettingWidth = computed(() => needFixedHeader.value && hasSiderMenu.value && !isTop.value && !isMobile.value);
const clearMenuData = computed(() => clearMenuItem(props.menuData || []));
const className = computed(() => {
return {
[`${prefixCls.value}-fixed-header`]: needFixedHeader.value,
[`${prefixCls.value}-top-menu`]: isTop.value,
}
})
const renderContent = () => {
let defaultDom = (
<GlobalHeader {...props} onCollapse={onCollapse.value} menuData={clearMenuData.value}>
{headerContentRender.value && headerContentRender.value(props)}
</GlobalHeader>
);
if (isTop.value && !isMobile.value) {
defaultDom = (
<TopNavHeader
theme={navTheme.value as 'light' | 'dark'}
mode="horizontal"
{...props}
onCollapse={onCollapse.value}
menuData={clearMenuData.value}
/>
);
}
if (headerRender.value && typeof headerRender.value === 'function') {
return headerRender.value(props, defaultDom);
}
return defaultDom;
}
/**
* 计算侧边栏的宽度,不然导致左边的样式会出问题
*/
const width = computed(() => {
return layout.value !== 'mix' && needSettingWidth.value
? `calc(100% - ${props.collapsed ? 48 : props.siderWidth}px)`
: '100%';
});
const right = computed(() => needFixedHeader.value ? 0 : undefined);
return () => (
<>
{needFixedHeader && (
<Header
style={{
height: headerHeight.value,
lineHeight: `${headerHeight.value}px`,
background: 'transparent',
}}
/>
)}
<Header
style={{
padding: 0,
height: headerHeight,
lineHeight: `${headerHeight}px`,
width,
zIndex: layout.value === 'mix' ? 100 : 19,
right,
}}
class={className}
>
{renderContent()}
</Header>
</>
);
},
});
export default HeaderView;
......@@ -16,11 +16,11 @@ import {
} from 'vue';
import { createFromIconfontCN } from '@ant-design/icons-vue';
import 'ant-design-vue/es/menu/style';
import Menu from 'ant-design-vue/es/menu';
import Menu, { MenuProps } from 'ant-design-vue/es/menu';
import defaultSettings, { PureSettings } from '../defaultSettings';
import { isImg, isUrl } from '../utils';
import { MenuMode, SelectInfo, OpenEventHandler } from './typings';
import { RouteProps, MenuTheme, FormatMessage, WithFalse } from '../typings';
import { MenuDataItem, MenuTheme, FormatMessage, WithFalse } from '../typings';
import './index.less';
export { MenuMode, SelectInfo, OpenEventHandler };
......@@ -66,7 +66,7 @@ export function useMenuState({
return [state, watchRef];
}
export function useRootSubmenuKeys(menus: RouteProps[]): ComputedRef<string[]> {
export function useRootSubmenuKeys(menus: MenuDataItem[]): ComputedRef<string[]> {
return computed(() => menus.map(it => it.path));
}
......@@ -76,7 +76,7 @@ export interface BaseMenuProps extends Partial<PureSettings> {
collapsed?: boolean;
splitMenus?: boolean;
isMobile?: boolean;
menuData?: RouteProps[];
menuData?: MenuDataItem[];
mode?: MenuMode;
onCollapse?: (collapsed: boolean) => void;
openKeys?: WithFalse<string[]> | undefined;
......@@ -89,14 +89,14 @@ export interface BaseMenuProps extends Partial<PureSettings> {
// vue props
export const VueBaseMenuProps = {
locale: Boolean,
menus: Array as PropType<RouteProps[]>,
menuData: Array as PropType<MenuDataItem[]>,
// top-nav-header: horizontal
mode: {
type: String as PropType<MenuMode>,
default: 'inline',
},
theme: {
type: String as PropType<MenuTheme>,
type: String as PropType<BaseMenuProps['theme']>,
default: 'dark',
},
collapsed: {
......@@ -119,7 +119,7 @@ const renderTitle = (title: string | undefined, i18nRender: FormatMessage) => {
return <span>{(i18nRender && title && i18nRender(title)) || title}</span>;
};
const renderMenuItem = (item: RouteProps, i18nRender: FormatMessage) => {
const renderMenuItem = (item: MenuDataItem, i18nRender: FormatMessage) => {
const meta = Object.assign({}, item.meta);
const target = meta.target || null;
const hasRemoteUrl = httpReg.test(item.path)
......@@ -145,7 +145,7 @@ const renderMenuItem = (item: RouteProps, i18nRender: FormatMessage) => {
);
};
const renderSubMenu = (item: RouteProps, i18nRender: FormatMessage) => {
const renderSubMenu = (item: MenuDataItem, i18nRender: FormatMessage) => {
const renderMenuContent = (
<span>
<LazyIcon icon={item.meta?.icon} />
......@@ -160,7 +160,7 @@ const renderSubMenu = (item: RouteProps, i18nRender: FormatMessage) => {
);
};
const renderMenu = (item: RouteProps, i18nRender: FormatMessage) => {
const renderMenu = (item: MenuDataItem, i18nRender: FormatMessage) => {
if (item && !item.hidden) {
const hasChild = item.children && !item.meta?.hideChildInMenu;
return hasChild ? renderSubMenu(item, i18nRender) : renderMenuItem(item, i18nRender);
......@@ -235,8 +235,8 @@ export default defineComponent({
onOpenChange={handleOpenChange}
onSelect={handleSelect}
>
{props.menus &&
props.menus.map(menu => {
{props.menuData &&
props.menuData.map(menu => {
if (menu.hidden) {
return null;
}
......
......@@ -13,7 +13,7 @@ import './index.less';
const { Sider } = Layout;
export type PrivateSiderMenuProps = {
matchMenuKeys: string[];
matchMenuKeys?: string[];
};
export interface SiderMenuProps
......
import { ref, computed, FunctionalComponent } from "vue";
import {
SiderMenuProps,
defaultRenderLogoAndTitle,
PrivateSiderMenuProps,
} from '../SiderMenu/SiderMenu';
import BaseMenu from '../SiderMenu/BaseMenu';
import { GlobalHeaderProps } from '../GlobalHeader';
import { default as ResizeObserver } from 'ant-design-vue/es/vc-resize-observer';
import './index.less';
export type TopNavHeaderProps = SiderMenuProps & GlobalHeaderProps & PrivateSiderMenuProps & {};
const RightContent: FunctionalComponent<TopNavHeaderProps> = ({ rightContentRender, ...props }) => {
const rightSize = ref<number | string>('auto');
return (
<div
style={{
minWidth: rightSize.value,
}}
>
<div
style={{
paddingRight: 8,
}}
>
<ResizeObserver
onResize={({ width }: { width: number }) => {
rightSize.value = width;
}}
>
{rightContentRender && (
<div>
{rightContentRender({
...props,
})}
</div>
)}
</ResizeObserver>
</div>
</div>
);
};
export const TopNavHeader: FunctionalComponent<TopNavHeaderProps> = (props) => {
const headerRef = ref();
const {
theme,
onMenuHeaderClick,
contentWidth,
rightContentRender,
layout,
} = props;
const prefixCls = `${props.prefixCls || 'ant-pro'}-top-nav-header`;
const headerDom = defaultRenderLogoAndTitle(
{ ...props, collapsed: false },
layout === 'mix' ? 'headerTitleRender' : undefined,
);
const className = computed(() => {
return {
[prefixCls]: true,
light: theme === 'light',
}
});
return (
<div class={className}>
<div ref={headerRef} class={`${prefixCls}-main ${contentWidth === 'Fixed' ? 'wide' : ''}`}>
{headerDom && (
<div class={`${prefixCls}-main-left`} onClick={onMenuHeaderClick}>
<div class={`${prefixCls}-logo`} key="logo" id="logo">
{headerDom}
</div>
</div>
)}
<div style={{ flex: 1 }} class={`${prefixCls}-menu`}>
<BaseMenu {...props} />
</div>
{rightContentRender && <RightContent rightContentRender={rightContentRender} {...props} />}
</div>
</div>
)
};
import { FunctionalComponent, reactive, toRefs, CSSProperties } from 'vue';
import { FunctionalComponent, computed, toRefs, CSSProperties } from 'vue';
import 'ant-design-vue/es/layout/style';
import Layout from 'ant-design-vue/es/layout';
import { useProProvider } from './ProProvider';
......@@ -18,13 +18,15 @@ export interface WrapContentProps {
export const WrapContent: FunctionalComponent<WrapContentProps> = (props, { slots, attrs }) => {
const { getPrefixCls } = toRefs(useProProvider());
const prefixCls = getPrefixCls.value('basicLayout');
const classNames = reactive({
[`${prefixCls}-content`]: true,
[`${prefixCls}-has-header`]: true,
});
const classNames = computed(() => {
return {
[`${prefixCls}-content`]: true,
[`${prefixCls}-has-header`]: true,
}
})
return (
<Content class={classNames} {...attrs}>
<Content class={classNames.value} {...attrs}>
{slots.default?.()}
</Content>
);
......
......@@ -18,6 +18,10 @@ export interface PureSettings {
* theme for nav menu
*/
navTheme: MenuTheme | 'realDark' | undefined;
/**
* @name 顶部菜单的颜色,mix 模式下生效
*/
headerTheme?: MenuTheme;
/**
* nav menu position: `side` or `top`
*/
......
......@@ -12,21 +12,55 @@ export type TargetType = '_blank' | '_self' | unknown;
export type ContentWidth = 'Fluid' | 'Fixed';
export interface MetaRecord {
/**
* @name 菜单的icon
*/
icon?: string | VNodeChild | JSX.Element;
/**
* @name 自定义菜单的国际化 key,如果没有则返回自身
*/
title?: string;
/**
* @name 内建授权信息
*/
authority?: string | string[];
target?: '_blank' | '_self' | string;
/**
* @name 打开目标位置 '_blank' | '_self' | null | undefined
*/
target?: TargetType;
/**
* @name 在菜单中隐藏子节点
*/
hideChildInMenu?: boolean;
/**
* @name 在菜单中隐藏自己和子节点
*/
hideInMenu?: boolean;
/**
* @name disable 菜单选项
*/
disabled?: boolean;
/**
* @name 隐藏自己,并且将子节点提升到与自己平级
*/
flatMenu?: boolean;
[key: string]: any;
}
export interface RouteProps {
export interface MenuDataItem {
/**
* @name 用于标定选中的值,默认是 path
*/
key?: string | symbol;
path: string;
name?: string | symbol;
meta?: MetaRecord;
target?: TargetType;
hidden?: boolean;
children?: RouteProps[];
/**
* @name 子菜单
*/
children?: MenuDataItem[];
}
export type WithFalse<T> = T | false;
......
import { Slots, VNodeChild } from 'vue';
import { MenuDataItem } from '../typings';
export { getComponent } from 'ant-design-vue/es/_util/props-util';
export { default as isUrl } from './isUrl';
export { default as isImg } from './isImg';
export { default as isNil } from './isNil';
export function getComponentOrSlot(props: any, slots: Slots, name: string): VNodeChild {
const comp = props[name] || slots[name];
return typeof comp === 'function' ? comp() : (comp && (comp as VNodeChild)) || false;
}
export function warn(valid: boolean, message: string) {
// Support uglify
if (process.env.NODE_ENV !== 'production' && !valid && console !== undefined) {
......@@ -15,6 +22,32 @@ export function warning(valid: boolean, message: string) {
warn(valid, `[@ant-design-vue/pro-layout] ${message}`);
}
export function clearMenuItem(menusData: MenuDataItem[]): MenuDataItem[] {
return menusData
.map(item => {
const finalItem = { ...item };
if (!finalItem.name || finalItem.meta?.hideInMenu) {
return null;
}
if (finalItem && finalItem?.children) {
if (
!finalItem.meta?.hideChildInMenu &&
finalItem.children.some(child => child && child.name && !child.meta?.hideInMenu)
) {
return {
...item,
children: clearMenuItem(finalItem.children),
};
}
// children 为空就直接删掉
delete finalItem.children;
}
return finalItem;
})
.filter(item => item) as MenuDataItem[];
}
export interface Attrs {
[key: string]: string;
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment