项目初始化

This commit is contained in:
Taric Xin
2021-11-26 16:34:35 +08:00
parent 66644bcf0a
commit 5287578452
354 changed files with 45736 additions and 0 deletions

View File

@ -0,0 +1,16 @@
<global-footer>
<global-footer-item href="https://e.ng-alain.com/theme/pro" blankTarget>
Pro 首页
</global-footer-item>
<global-footer-item href="https://github.com/ng-alain" blankTarget>
<i nz-icon nzType="github"></i>
</global-footer-item>
<global-footer-item href="https://ng-alain.github.io/ng-alain/" blankTarget>
Alain Pro
</global-footer-item>
Copyright
<i nz-icon nzType="copyright" class="mx-sm"></i>
{{ year }}
<a href="//github.com/cipchk" target="_blank" class="mx-sm">卡色</a>
出品
</global-footer>

View File

@ -0,0 +1,15 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { SettingsService } from '@delon/theme';
@Component({
selector: 'layout-pro-footer',
templateUrl: './footer.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class LayoutProFooterComponent {
get year(): number {
return this.setting.app.year;
}
constructor(private setting: SettingsService) {}
}

View File

@ -0,0 +1,25 @@
<div *ngIf="pro.isTopMenu" class="alain-pro__top-nav">
<div class="alain-pro__top-nav-main" [ngClass]="{ 'alain-pro__top-nav-main-wide': pro.isFixed }">
<div class="alain-pro__top-nav-main-left">
<layout-pro-logo class="alain-pro__top-nav-logo"></layout-pro-logo>
<div class="alain-pro__menu-wrap">
<div layout-pro-menu mode="horizontal"></div>
</div>
</div>
<div class="alain-pro__top-nav-main-right" layout-pro-header-widget></div>
</div>
</div>
<div *ngIf="!pro.isTopMenu" class="alain-pro__header">
<div class="d-flex align-items-center">
<ng-container *ngIf="pro.isMobile">
<a [routerLink]="['/']" class="alain-pro__header-logo">
<img src="./assets/logo-color.svg" width="32" />
</a>
<div class="ant-divider ant-divider-vertical"></div>
</ng-container>
<div class="alain-pro__header-item alain-pro__header-trigger" (click)="pro.setCollapsed()">
<i nz-icon [nzType]="collapsedIcon" class="alain-pro__header-item-icon"></i>
</div>
</div>
<div layout-pro-header-widget></div>
</div>

View File

@ -0,0 +1,68 @@
import { DOCUMENT } from '@angular/common';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostBinding, Inject, OnDestroy, OnInit } from '@angular/core';
import { RTL, RTLService } from '@delon/theme';
import { combineLatest, fromEvent, Subject } from 'rxjs';
import { distinctUntilChanged, takeUntil, tap, throttleTime } from 'rxjs/operators';
import { BrandService } from '../../pro.service';
@Component({
selector: 'layout-pro-header',
templateUrl: './header.component.html',
host: {
'[class.ant-layout-header]': 'true',
'[class.alain-pro__header-fixed]': 'pro.fixedHeader',
'[class.alain-pro__header-hide]': 'hideHeader',
'[style.padding.px]': '0'
},
changeDetection: ChangeDetectionStrategy.OnPush
})
export class LayoutProHeaderComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
hideHeader = false;
@HostBinding('style.width')
get getHeadWidth(): string {
const { isMobile, fixedHeader, menu, collapsed, width, widthInCollapsed } = this.pro;
if (isMobile || !fixedHeader || menu === 'top') {
return '100%';
}
return collapsed ? `calc(100% - ${widthInCollapsed}px)` : `calc(100% - ${width}px)`;
}
get collapsedIcon(): string {
let type = this.pro.collapsed ? 'unfold' : 'fold';
if (this.rtl.dir === RTL) {
type = this.pro.collapsed ? 'fold' : 'unfold';
}
return `menu-${type}`;
}
constructor(public pro: BrandService, @Inject(DOCUMENT) private doc: any, private cdr: ChangeDetectorRef, private rtl: RTLService) {}
private handScroll(): void {
if (!this.pro.autoHideHeader) {
this.hideHeader = false;
return;
}
setTimeout(() => {
this.hideHeader = this.doc.body.scrollTop + this.doc.documentElement.scrollTop > this.pro.autoHideHeaderTop;
});
}
ngOnInit(): void {
combineLatest([
this.pro.notify.pipe(tap(() => this.cdr.markForCheck())),
fromEvent(window, 'scroll', { passive: false }).pipe(throttleTime(50), distinctUntilChanged())
])
.pipe(takeUntil(this.destroy$))
.subscribe(() => this.handScroll());
this.rtl.change.pipe(takeUntil(this.destroy$)).subscribe(() => this.cdr.detectChanges());
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@ -0,0 +1,4 @@
<a [routerLink]="['/']" class="d-flex align-items-center">
<img src="./assets/logo-color.svg" alt="{{ name }}" height="32" />
<h1>{{ name }}</h1>
</a>

View File

@ -0,0 +1,15 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { SettingsService } from '@delon/theme';
@Component({
selector: 'layout-pro-logo',
templateUrl: './logo.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class LayoutProLogoComponent {
get name(): string {
return this.setting.app.name!;
}
constructor(private setting: SettingsService) {}
}

View File

@ -0,0 +1,101 @@
<ng-template #icon let-i>
<ng-container *ngIf="i" [ngSwitch]="i.type">
<i *ngSwitchCase="'icon'" nz-icon [nzType]="i.value" class="alain-pro__menu-icon"></i>
<i *ngSwitchCase="'iconfont'" nz-icon [nzIconfont]="i.iconfont" class="alain-pro__menu-icon"></i>
<img *ngSwitchCase="'img'" src="{{ i.value }}" class="anticon alain-pro__menu-icon alain-pro__menu-img" />
<i *ngSwitchDefault class="anticon alain-pro__menu-icon {{ i.value }}"></i>
</ng-container>
</ng-template>
<ng-template #mainLink let-i>
<ng-template [ngTemplateOutlet]="icon" [ngTemplateOutletContext]="{ $implicit: i.icon }"></ng-template>
<span class="alain-pro__menu-title-text" *ngIf="!pro.onlyIcon">{{ i.text }}</span>
<div *ngIf="i.badge" class="alain-pro__menu-title-badge">
<em>{{ i.badge }}</em>
</div>
</ng-template>
<ng-template #subLink let-i>
<a *ngIf="!i.externalLink" [routerLink]="i.link" [target]="i.target">{{ i.text }} </a>
<a *ngIf="i.externalLink" [attr.href]="i.externalLink" [attr.target]="i.target">{{ i.text }} </a>
</ng-template>
<ul *ngIf="menus" nz-menu [nzMode]="mode" [nzTheme]="pro.theme" [nzInlineCollapsed]="pro.isMobile ? false : pro.collapsed">
<ng-container *ngFor="let l1 of menus">
<li
*ngIf="l1.children!.length === 0"
nz-menu-item
class="alain-pro__menu-item"
[class.alain-pro__menu-item--disabled]="l1.disabled"
[nzSelected]="l1._selected"
[nzDisabled]="l1.disabled"
>
<a *ngIf="!l1.externalLink" [routerLink]="l1.link" (click)="closeCollapsed()" class="alain-pro__menu-title">
<ng-template [ngTemplateOutlet]="mainLink" [ngTemplateOutletContext]="{ $implicit: l1 }"></ng-template>
</a>
<a
*ngIf="l1.externalLink"
[attr.href]="l1.externalLink"
[attr.target]="l1.target"
(click)="closeCollapsed()"
class="alain-pro__menu-title"
>
<ng-template [ngTemplateOutlet]="mainLink" [ngTemplateOutletContext]="{ $implicit: l1 }"></ng-template>
</a>
</li>
<li
*ngIf="l1.children!.length > 0"
nz-submenu
[nzTitle]="l1TitleTpl"
class="alain-pro__menu-item"
[class.text-white]="pro.theme === 'dark' && l1._selected"
[nzOpen]="l1._open"
[nzDisabled]="l1.disabled"
(nzOpenChange)="openChange(l1, $event)"
>
<ng-template #l1TitleTpl>
<span title class="alain-pro__menu-title">
<ng-template [ngTemplateOutlet]="icon" [ngTemplateOutletContext]="{ $implicit: l1.icon }"></ng-template>
<span class="alain-pro__menu-title-text" *ngIf="pro.isMobile || !pro.onlyIcon">{{ l1.text }}</span>
<div *ngIf="l1.badge" class="alain-pro__menu-title-badge">
<em>{{ l1.badge }}</em>
</div>
</span>
</ng-template>
<ul>
<ng-container *ngFor="let l2 of l1.children">
<li
*ngIf="!l2._hidden && l2.children!.length === 0"
nz-menu-item
[class.alain-pro__menu-item--disabled]="l2.disabled"
[nzSelected]="l2._selected"
[nzDisabled]="l2.disabled"
(click)="closeCollapsed()"
>
<ng-template [ngTemplateOutlet]="subLink" [ngTemplateOutletContext]="{ $implicit: l2 }"></ng-template>
</li>
<li
*ngIf="!l2._hidden && l2.children!.length > 0"
nz-submenu
[nzTitle]="l2.text!"
[nzOpen]="l2._open"
[nzDisabled]="l2.disabled"
(nzOpenChange)="openChange(l2, $event)"
>
<ul>
<ng-container *ngFor="let l3 of l2.children">
<li
*ngIf="!l3._hidden"
nz-menu-item
[class.alain-pro__menu-item--disabled]="l3.disabled"
[nzSelected]="l3._selected"
[nzDisabled]="l3.disabled"
(click)="closeCollapsed()"
>
<ng-template [ngTemplateOutlet]="subLink" [ngTemplateOutletContext]="{ $implicit: l3 }"></ng-template>
</li>
</ng-container>
</ul>
</li>
</ng-container>
</ul>
</li>
</ng-container>
</ul>

View File

@ -0,0 +1,125 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy, OnInit } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { MenuService } from '@delon/theme';
import { InputBoolean } from '@delon/util';
import { NzMenuModeType } from 'ng-zorro-antd/menu';
import { Subject } from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators';
import { BrandService } from '../../pro.service';
import { ProMenu } from '../../pro.types';
@Component({
selector: '[layout-pro-menu]',
templateUrl: './menu.component.html',
host: {
'[class.alain-pro__menu]': 'true',
'[class.alain-pro__menu-only-icon]': 'pro.onlyIcon'
},
changeDetection: ChangeDetectionStrategy.OnPush
})
export class LayoutProMenuComponent implements OnInit, OnDestroy {
private unsubscribe$ = new Subject<void>();
menus?: ProMenu[];
@Input() @InputBoolean() disabledAcl = false;
@Input() mode: NzMenuModeType = 'inline';
constructor(private menuSrv: MenuService, private router: Router, public pro: BrandService, private cdr: ChangeDetectorRef) {}
private cd(): void {
this.cdr.markForCheck();
}
private genMenus(data: ProMenu[]): void {
const res: ProMenu[] = [];
// ingores category menus
const ingoreCategores = data.reduce((prev, cur) => prev.concat(cur.children as ProMenu[]), [] as ProMenu[]);
this.menuSrv.visit(ingoreCategores, (item: ProMenu, parent: ProMenu | null) => {
if (!item._aclResult) {
if (this.disabledAcl) {
item.disabled = true;
} else {
item._hidden = true;
}
}
if (item._hidden === true) {
return;
}
if (parent === null) {
res.push(item);
}
});
this.menus = res;
this.openStatus();
}
private openStatus(): void {
const inFn = (list: ProMenu[]) => {
for (const i of list) {
i._open = false;
i._selected = false;
if (i.children!.length > 0) {
inFn(i.children!);
}
}
};
inFn(this.menus!);
let item = this.menuSrv.getHit(this.menus!, this.router.url, true);
if (!item) {
this.cd();
return;
}
do {
item._selected = true;
if (!this.pro.isTopMenu && !this.pro.collapsed) {
item._open = true;
}
item = item._parent!;
} while (item);
this.cd();
}
openChange(item: ProMenu, statue: boolean): void {
const data = item._parent ? item._parent.children : this.menus;
if (data && data.length <= 1) {
return;
}
data!.forEach(i => (i._open = false));
item._open = statue;
}
closeCollapsed(): void {
const { pro } = this;
if (pro.isMobile) {
setTimeout(() => pro.setCollapsed(true), 25);
}
}
ngOnInit(): void {
const { unsubscribe$, router, pro } = this;
this.menuSrv.change.pipe(takeUntil(unsubscribe$)).subscribe(res => this.genMenus(res));
router.events
.pipe(
takeUntil(unsubscribe$),
filter(e => e instanceof NavigationEnd)
)
.subscribe(() => this.openStatus());
pro.notify
.pipe(
takeUntil(unsubscribe$),
filter(() => !!this.menus)
)
.subscribe(() => this.cd());
}
ngOnDestroy(): void {
const { unsubscribe$ } = this;
unsubscribe$.next();
unsubscribe$.complete();
}
}

View File

@ -0,0 +1,10 @@
<notice-icon
btnClass="alain-pro__header-item"
btnIconClass="alain-pro__header-item-icon"
[data]="data"
[count]="count"
[loading]="loading"
(select)="select($event)"
(clear)="clear($event)"
(popoverVisibleChange)="loadData()"
></notice-icon>

View File

@ -0,0 +1,183 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component } from '@angular/core';
import { NoticeIconList, NoticeItem } from '@delon/abc/notice-icon';
import formatDistanceToNow from 'date-fns/formatDistanceToNow';
import parse from 'date-fns/parse';
import { NzSafeAny } from 'ng-zorro-antd/core/types';
import { NzMessageService } from 'ng-zorro-antd/message';
@Component({
selector: 'layout-pro-notify',
templateUrl: './notify.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class LayoutProWidgetNotifyComponent {
data: NoticeItem[] = [
{
title: '通知',
list: [],
emptyText: '你已查看所有通知',
emptyImage: 'https://gw.alipayobjects.com/zos/rmsportal/wAhyIChODzsoKIOBHcBk.svg',
clearText: '清空通知'
},
{
title: '消息',
list: [],
emptyText: '您已读完所有消息',
emptyImage: 'https://gw.alipayobjects.com/zos/rmsportal/sAuJeJzSKbUmHfBQRzmZ.svg',
clearText: '清空消息'
},
{
title: '待办',
list: [],
emptyText: '你已完成所有待办',
emptyImage: 'https://gw.alipayobjects.com/zos/rmsportal/HsIsxMZiWKrNUavQUXqx.svg',
clearText: '清空待办'
}
];
count = 5;
loading = false;
constructor(private msg: NzMessageService, private cdr: ChangeDetectorRef) {}
updateNoticeData(notices: NoticeIconList[]): NoticeItem[] {
const data = this.data.slice();
data.forEach(i => (i.list = []));
notices.forEach(item => {
const newItem = { ...item };
if (typeof newItem.datetime === 'string') {
newItem.datetime = parse(newItem.datetime, 'yyyy-MM-dd', new Date());
}
if (newItem.datetime) {
newItem.datetime = formatDistanceToNow(newItem.datetime as Date);
}
if (newItem.extra && newItem.status) {
newItem.color = (
{
todo: undefined,
processing: 'blue',
urgent: 'red',
doing: 'gold'
} as NzSafeAny
)[newItem.status];
}
data.find(w => w.title === newItem.type)?.list.push(newItem);
});
return data;
}
loadData(): void {
if (this.loading) {
return;
}
this.loading = true;
setTimeout(() => {
this.data = this.updateNoticeData([
{
id: '000000001',
avatar: 'https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png',
title: '你收到了 14 份新周报',
datetime: '2017-08-09',
type: '通知'
},
{
id: '000000002',
avatar: 'https://gw.alipayobjects.com/zos/rmsportal/OKJXDXrmkNshAMvwtvhu.png',
title: '你推荐的 曲妮妮 已通过第三轮面试',
datetime: '2017-08-08',
type: '通知'
},
{
id: '000000003',
avatar: 'https://gw.alipayobjects.com/zos/rmsportal/kISTdvpyTAhtGxpovNWd.png',
title: '这种模板可以区分多种通知类型',
datetime: '2017-08-07',
read: true,
type: '通知'
},
{
id: '000000004',
avatar: 'https://gw.alipayobjects.com/zos/rmsportal/GvqBnKhFgObvnSGkDsje.png',
title: '左侧图标用于区分不同的类型',
datetime: '2017-08-07',
type: '通知'
},
{
id: '000000005',
avatar: 'https://gw.alipayobjects.com/zos/rmsportal/ThXAXghbEsBCCSDihZxY.png',
title: '内容不要超过两行字,超出时自动截断',
datetime: '2017-08-07',
type: '通知'
},
{
id: '000000006',
avatar: 'https://gw.alipayobjects.com/zos/rmsportal/fcHMVNCjPOsbUGdEduuv.jpeg',
title: '曲丽丽 评论了你',
description: '描述信息描述信息描述信息',
datetime: '2017-08-07',
type: '消息'
},
{
id: '000000007',
avatar: 'https://gw.alipayobjects.com/zos/rmsportal/fcHMVNCjPOsbUGdEduuv.jpeg',
title: '朱偏右 回复了你',
description: '这种模板用于提醒谁与你发生了互动,左侧放『谁』的头像',
datetime: '2017-08-07',
type: '消息'
},
{
id: '000000008',
avatar: 'https://gw.alipayobjects.com/zos/rmsportal/fcHMVNCjPOsbUGdEduuv.jpeg',
title: '标题',
description: '这种模板用于提醒谁与你发生了互动,左侧放『谁』的头像',
datetime: '2017-08-07',
type: '消息'
},
{
id: '000000009',
title: '任务名称',
description: '任务需要在 2017-01-12 20:00 前启动',
extra: '未开始',
status: 'todo',
type: '待办'
},
{
id: '000000010',
title: '第三方紧急代码变更',
description: '冠霖提交于 2017-01-06需在 2017-01-07 前完成代码变更任务',
extra: '马上到期',
status: 'urgent',
type: '待办'
},
{
id: '000000011',
title: '信息安全考试',
description: '指派竹尔于 2017-01-09 前完成更新并发布',
extra: '已耗时 8 天',
status: 'doing',
type: '待办'
},
{
id: '000000012',
title: 'ABCD 版本发布',
description: '冠霖提交于 2017-01-06需在 2017-01-07 前完成代码变更任务',
extra: '进行中',
status: 'processing',
type: '待办'
}
]);
this.loading = false;
this.cdr.detectChanges();
}, 1000);
}
clear(type: string): void {
this.msg.success(`清空了 ${type}`);
}
select(res: any): void {
this.msg.success(`点击了 ${res.title}${res.item.title}`);
}
}

View File

@ -0,0 +1,4 @@
<div class="alain-pro__header-item position-relative" (click)="show()">
<nz-badge class="brand-top-right" style="right: 3px; line-height: 34px" nzShowDot [nzStatus]="status"></nz-badge>
<i nz-tooltip="Public Chat" nz-icon nzType="message" class="alain-pro__header-item-icon"></i>
</div>

View File

@ -0,0 +1,42 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { Subscription } from 'rxjs';
import { LayoutProWidgetQuickChatService } from './quick-chat.service';
@Component({
selector: 'quick-chat-status',
templateUrl: './quick-chat-status.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class LayoutProWidgetQuickChatStatusComponent implements OnInit, OnDestroy {
private status$!: Subscription;
status = 'default';
constructor(private srv: LayoutProWidgetQuickChatService, private cdr: ChangeDetectorRef) {}
show(): void {
if (this.srv.showDialog) {
return;
}
this.srv.showDialog = true;
}
ngOnInit(): void {
this.status$ = this.srv.status.subscribe(res => {
switch (res) {
case 'online':
this.status = 'success';
break;
default:
this.status = 'default';
break;
}
this.cdr.detectChanges();
});
}
ngOnDestroy(): void {
this.status$.unsubscribe();
}
}

View File

@ -0,0 +1,47 @@
<div class="quick-chat__bar">
<strong class="quick-chat__bar--title" (click)="toggleCollapsed()">
<div [ngClass]="{ 'quick-chat__bar--title-has-message': collapsed && hasMessage }">
{{ !collapsed && inited ? 'Connecting...' : 'Ng Alain Pro' }}
</div>
</strong>
<i nz-dropdown [nzDropdownMenu]="quickMenu" nz-icon nzType="ellipsis" class="quick-chat__bar--menu rotate-90"></i>
<nz-dropdown-menu #quickMenu="nzDropdownMenu">
<ul nz-menu nzSelectable>
<li nz-menu-item>Add</li>
<li nz-menu-item>Edit</li>
<li nz-menu-item>Remove</li>
</ul>
</nz-dropdown-menu>
<i nz-icon nzType="close" class="quick-chat__bar--close" (click)="close()"></i>
</div>
<div class="quick-chat__body" [ngClass]="{ 'quick-chat__collapsed': collapsed }">
<div class="quick-chat__content">
<div class="chat__scroll-container chat__message-container" scrollbar #messageScrollbar="scrollbarComp">
<div *ngFor="let m of messages" class="chat__message chat__message-{{ m.dir }}">
<ng-container [ngSwitch]="m.type">
<div *ngSwitchCase="'only-text'" class="chat__message-text" [innerHTML]="m.msg"></div>
<ng-container *ngSwitchDefault>
<div class="chat__message-avatar" *ngIf="m.dir === 'left'">
<img class="chat__user-avatar" src="{{ m.mp }}" />
</div>
<div class="chat__message-msg">
<strong class="chat__message-msg--name" *ngIf="m.name">{{ m.name }}</strong>
<div class="chat__message-msg--text" *ngIf="m.type === 'text'" [innerHTML]="m.msg"></div>
<div class="chat__message-msg--image" *ngIf="m.type === 'image'">
<img height="40" src="{{ m.msg }}" />
</div>
</div>
</ng-container>
</ng-container>
</div>
</div>
</div>
<div class="quick-chat__reply">
<textarea
class="quick-chat__reply--ipt scrollbar"
[(ngModel)]="text"
(keydown.enter)="enterSend($event)"
placeholder="Type your message..."
></textarea>
</div>
</div>

View File

@ -0,0 +1,131 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
EventEmitter,
HostBinding,
Input,
OnDestroy,
OnInit,
Output,
ViewChild
} from '@angular/core';
import { BooleanInput, InputBoolean, InputNumber, NumberInput } from '@delon/util';
import { ScrollbarDirective } from '@shared';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { LayoutProWidgetQuickChatService } from './quick-chat.service';
@Component({
selector: 'quick-chat',
templateUrl: './quick-chat.component.html',
host: {
'[class.quick-chat]': 'true',
'[class.quick-chat__collapsed]': 'collapsed',
'[class.d-none]': '!showDialog'
},
changeDetection: ChangeDetectionStrategy.OnPush
})
export class LayoutProWidgetQuickChatComponent implements OnInit, OnDestroy {
static ngAcceptInputType_height: NumberInput;
static ngAcceptInputType_width: BooleanInput;
static ngAcceptInputType_collapsed: BooleanInput;
private unsubscribe$ = new Subject<void>();
messages: any[] = [
{ type: 'only-text', msg: '2018-12-12' },
{
type: 'text',
dir: 'left',
mp: './assets/logo-color.svg',
msg: '请<span class="text-success">一句话</span>描述您的问题,我们来帮您解决并转到合适的人工服务。😎'
}
];
text = '';
inited?: boolean;
hasMessage = false;
@ViewChild('messageScrollbar', { static: true }) messageScrollbar?: ScrollbarDirective;
// #region fileds
@Input() @InputNumber() height = 380;
@Input() @InputNumber() @HostBinding('style.width.px') width = 320;
@Input() @InputBoolean() collapsed = true;
@Output() readonly collapsedChange = new EventEmitter<boolean>();
@Output() readonly closed = new EventEmitter<boolean>();
// #endregion
constructor(private srv: LayoutProWidgetQuickChatService, private cdr: ChangeDetectorRef) {}
get showDialog(): boolean {
return this.srv.showDialog;
}
private scrollToBottom(): void {
this.cdr.detectChanges();
setTimeout(() => this.messageScrollbar!.scrollToBottom());
}
toggleCollapsed(): void {
this.hasMessage = false;
this.collapsed = !this.collapsed;
this.collapsedChange.emit(this.collapsed);
}
close(): void {
this.srv.close();
this.closed.emit(true);
}
enterSend(e: Event): void {
if ((e as KeyboardEvent).keyCode !== 13) {
return;
}
if (e) {
e.preventDefault();
e.stopPropagation();
}
this.send();
}
send(): boolean {
if (!this.text) {
return false;
}
if (typeof this.inited === 'undefined') {
this.inited = true;
}
const item = {
type: 'text',
msg: this.text,
dir: 'right'
};
this.srv.send(item);
this.messages.push(item);
this.text = '';
this.scrollToBottom();
return false;
}
ngOnInit(): void {
const { srv, messages, unsubscribe$ } = this;
srv.message.pipe(takeUntil(unsubscribe$)).subscribe(res => {
if (this.collapsed) {
this.hasMessage = true;
}
messages.push(res);
this.scrollToBottom();
});
srv.status.pipe(takeUntil(unsubscribe$)).subscribe(res => {
this.inited = res === 'online' ? false : undefined;
});
}
ngOnDestroy(): void {
const { unsubscribe$ } = this;
unsubscribe$.next();
unsubscribe$.complete();
}
}

View File

@ -0,0 +1,80 @@
import { Injectable, OnDestroy } from '@angular/core';
import { Observable, Subject, Subscription } from 'rxjs';
import { webSocket, WebSocketSubject } from 'rxjs/webSocket';
export type QuickChatStatus = 'online' | 'offline';
@Injectable({ providedIn: 'root' })
export class LayoutProWidgetQuickChatService implements OnDestroy {
private url = 'wss://echo.websocket.org/?encoding=text';
private _ws!: WebSocketSubject<{}>;
private $statusOrg = new Subject();
private messageOrg$: Subscription | null = null;
private $status = new Subject<QuickChatStatus>();
private $message = new Subject<{}>();
showDialog = true;
constructor() {
this.$statusOrg.subscribe((res: any) => {
this.$status.next(res.type === 'open' ? 'online' : 'offline');
});
}
get ws(): WebSocketSubject<{}> {
return this._ws!;
}
get message(): Observable<{}> {
return this.$message.asObservable();
}
get status(): Observable<QuickChatStatus> {
return this.$status.asObservable();
}
open(): WebSocketSubject<{}> {
if (this._ws) {
return this._ws;
}
this._ws = webSocket({
url: this.url,
serializer: (value: any) => JSON.stringify(value),
deserializer: (e: MessageEvent) => {
const res = JSON.parse(e.data);
res.dir = 'left';
res.mp = './assets/logo-color.svg';
return res;
},
openObserver: this.$statusOrg,
closeObserver: this.$statusOrg
});
return this._ws;
}
close(): void {
this.showDialog = false;
if (this.messageOrg$) {
this.messageOrg$.unsubscribe();
this.messageOrg$ = null;
}
}
send(msg: {}): void {
if (!this._ws) {
this.open();
}
if (!this.messageOrg$) {
this.messageOrg$ = this._ws.subscribe(res => this.$message.next(res));
}
this._ws.next(msg);
}
ngOnDestroy(): void {
const { $statusOrg, $status, $message } = this;
this.close();
$statusOrg.complete();
$status.complete();
$message.complete();
}
}

View File

@ -0,0 +1,87 @@
<div class="p-md border-bottom-1">
<button (click)="changeType(0)" nz-button [nzType]="type === 0 ? 'primary' : 'default'">Notifications</button>
<button (click)="changeType(1)" nz-button [nzType]="type === 1 ? 'primary' : 'default'">Actions</button>
<button (click)="changeType(2)" nz-button [nzType]="type === 2 ? 'primary' : 'default'">Settings</button>
</div>
<nz-spin [nzSpinning]="!data">
<div *ngIf="!data" class="brand-page-loading"></div>
<div *ngIf="data" class="p-lg min-width-lg">
<nz-timeline *ngIf="type === 0" class="d-block pl-md pt-md">
<nz-timeline-item *ngFor="let i of data.notifications" [nzDot]="dotTpl">
<ng-template #dotTpl>
<div class="md-sm p-sm icon-sm rounded-circle text-white bg-{{ i.dot.bg }}">
<i nz-icon [nzType]="i.dot.icon"></i>
</div>
</ng-template>
<div class="pl-lg">
<strong>{{ i.time }}</strong>
<div class="py-sm" [innerHTML]="i.content | html"></div>
<div class="text-grey">{{ i.tags }}</div>
</div>
</nz-timeline-item>
</nz-timeline>
<ng-container *ngIf="type === 1">
<div
*ngFor="let i of data.actions; let last = last"
class="rounded-md text-white position-relative bg-{{ i.bg }}"
[ngClass]="{ 'mb-md': !last }"
>
<strong class="d-block p-md">{{ i.title }}</strong>
<div class="px-md">{{ i.content }}</div>
<div class="p-sm text-right">
<button (click)="msg.success('Dismiss')" nz-button class="btn-flat text-white text-hover">Dismiss</button>
<button (click)="msg.success('View')" nz-button class="btn-flat text-white text-hover">View</button>
</div>
<span nz-dropdown [nzDropdownMenu]="actionMenu" nzPlacement="bottomRight" class="dd-btn brand-top-right text-white">
<i nz-icon nzType="ellipsis"></i>
</span>
<nz-dropdown-menu #actionMenu="nzDropdownMenu">
<ul nz-menu>
<li nz-menu-item (click)="msg.success('Item1')">Item1</li>
<li nz-menu-item (click)="msg.success('Item2')">Item2</li>
<li nz-menu-divider></li>
<li nz-menu-item (click)="msg.success('Item3')">Item3</li>
</ul>
</nz-dropdown-menu>
</div>
</ng-container>
<ng-container *ngIf="type === 2">
<h3 class="setting-drawer__title">Notifications</h3>
<div class="setting-drawer__body-item">
Enable notifications:
<nz-switch [(ngModel)]="data.settings.notification" (ngModelChange)="updateSetting('notification', $event)"></nz-switch>
</div>
<div class="setting-drawer__body-item">
Enable audit log:
<nz-switch [(ngModel)]="data.settings.audit_log" (ngModelChange)="updateSetting('audit_log', $event)"></nz-switch>
</div>
<div class="setting-drawer__body-item">
Notify on new orders:
<nz-switch [(ngModel)]="data.settings.new_order" (ngModelChange)="updateSetting('new_order', $event)"></nz-switch>
</div>
<h3 class="setting-drawer__title mt-md">Orders</h3>
<div class="setting-drawer__body-item">
Enable order tracking:
<nz-switch [(ngModel)]="data.settings.tracking_order" (ngModelChange)="updateSetting('tracking_order', $event)"></nz-switch>
</div>
<div class="setting-drawer__body-item">
Enable orders reports:
<nz-switch [(ngModel)]="data.settings.reports_order" (ngModelChange)="updateSetting('reports_order', $event)"></nz-switch>
</div>
<h3 class="setting-drawer__title mt-md">Customers</h3>
<div class="setting-drawer__body-item">
Enable customer singup:
<nz-switch [(ngModel)]="data.settings.new_customer" (ngModelChange)="updateSetting('new_customer', $event)"></nz-switch>
</div>
<div class="setting-drawer__body-item">
Enable customers reporting:
<nz-switch [(ngModel)]="data.settings.reporting_customer" (ngModelChange)="updateSetting('reporting_customer', $event)"></nz-switch>
</div>
<h3 class="setting-drawer__title mt-md">Other</h3>
<div class="setting-drawer__body-item">
Weak Mode:
<nz-switch [(ngModel)]="layout.colorWeak" (ngModelChange)="setLayout('colorWeak', $event)"></nz-switch>
</div>
</ng-container>
</div>
</nz-spin>

View File

@ -0,0 +1,42 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { _HttpClient } from '@delon/theme';
import { NzMessageService } from 'ng-zorro-antd/message';
import { BrandService } from '../../pro.service';
import { ProLayout } from '../../pro.types';
@Component({
selector: 'layout-pro-quick-panel',
templateUrl: './quick-panel.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class LayoutProWidgetQuickPanelComponent implements OnInit {
type = 0;
data: any;
get layout(): ProLayout {
return this.pro.layout;
}
constructor(private pro: BrandService, private http: _HttpClient, private cd: ChangeDetectorRef, public msg: NzMessageService) {}
ngOnInit(): void {
this.http.get('/quick').subscribe(res => {
this.data = res;
this.changeType(0);
});
}
changeType(type: number): void {
this.type = type;
// wait checkbox & switch render
setTimeout(() => this.cd.detectChanges());
}
updateSetting(_type: string, _value: any): void {
this.msg.success('Success!');
}
setLayout(name: string, value: any): void {
this.pro.setLayout(name, value);
}
}

View File

@ -0,0 +1 @@
<i nz-tooltip="Quick panel" nz-icon nzType="appstore" class="alain-pro__header-item-icon"></i>

View File

@ -0,0 +1,51 @@
import { Direction, Directionality } from '@angular/cdk/bidi';
import { ChangeDetectionStrategy, Component, HostListener, OnDestroy, OnInit, Optional } from '@angular/core';
import { DrawerHelper } from '@delon/theme';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { LayoutProWidgetQuickPanelComponent } from './quick-panel.component';
@Component({
selector: 'layout-pro-quick',
templateUrl: './quick.component.html',
host: {
'[class.alain-pro__header-item]': 'true'
},
changeDetection: ChangeDetectionStrategy.OnPush
})
export class LayoutProWidgetQuickComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
private dir: Direction = 'ltr';
constructor(private drawerHelper: DrawerHelper, @Optional() private directionality: Directionality) {}
@HostListener('click')
show(): void {
this.drawerHelper
.create(``, LayoutProWidgetQuickPanelComponent, null, {
size: 480,
drawerOptions: {
nzTitle: undefined,
nzPlacement: this.dir === 'rtl' ? 'left' : 'right',
nzBodyStyle: {
'min-height': '100%',
padding: 0
}
}
})
.subscribe();
}
ngOnInit(): void {
this.dir = this.directionality.value;
this.directionality.change?.pipe(takeUntil(this.destroy$)).subscribe((direction: Direction) => {
this.dir = direction;
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@ -0,0 +1,23 @@
import { ChangeDetectionStrategy, Component, HostListener } from '@angular/core';
import { RTLService } from '@delon/theme';
@Component({
selector: 'layout-pro-rtl',
template: `
<button nz-button nzType="link" class="alain-pro__header-item-icon">
{{ rtl.nextDir | uppercase }}
</button>
`,
host: {
'[class.alain-pro__header-item]': 'true'
},
changeDetection: ChangeDetectionStrategy.OnPush
})
export class LayoutProWidgetRTLComponent {
constructor(public rtl: RTLService) {}
@HostListener('click')
toggleDirection(): void {
this.rtl.toggle();
}
}

View File

@ -0,0 +1,9 @@
<i nz-icon nzType="search"></i>
<div class="alain-pro__header-search-input ant-select-auto-complete ant-select">
<input #ipt placeholder="站内搜索" nz-input [nzAutocomplete]="searchAuto" [(ngModel)]="q" (input)="onSearch()" (blur)="show = false" />
</div>
<nz-autocomplete #searchAuto>
<nz-auto-option *ngFor="let item of list" [nzValue]="item.no">
{{ item.no }}
</nz-auto-option>
</nz-autocomplete>

View File

@ -0,0 +1,49 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, HostListener, OnDestroy, ViewChild } from '@angular/core';
import { _HttpClient } from '@delon/theme';
import { Subject } from 'rxjs';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
@Component({
selector: 'layout-pro-search',
templateUrl: 'search.component.html',
host: {
'[class.alain-pro__header-item]': 'true',
'[class.alain-pro__header-search]': 'true',
'[class.alain-pro__header-search-show]': 'show'
},
changeDetection: ChangeDetectionStrategy.OnPush
})
export class LayoutProWidgetSearchComponent implements OnDestroy {
@ViewChild('ipt', { static: true }) private ipt!: ElementRef<HTMLInputElement>;
show = false;
q = '';
search$ = new Subject<string>();
list: any[] = [];
constructor(http: _HttpClient, cdr: ChangeDetectorRef) {
this.search$
.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((q: string) => http.get('/user', { no: q, pi: 1, ps: 5 }))
)
.subscribe((res: any) => {
this.list = res.list;
cdr.detectChanges();
});
}
onSearch(): void {
this.search$.next(this.ipt.nativeElement.value);
}
@HostListener('click')
_click(): void {
this.ipt.nativeElement.focus();
this.show = true;
}
ngOnDestroy(): void {
this.search$.unsubscribe();
}
}

View File

@ -0,0 +1,25 @@
<div nz-dropdown [nzDropdownMenu]="userMenu" nzPlacement="bottomRight" class="alain-pro__header-item">
<nz-avatar [nzSrc]="settings.user.avatar" nzSize="small" class="mr-sm"></nz-avatar>
{{ settings.user.name }}
</div>
<nz-dropdown-menu #userMenu="nzDropdownMenu">
<div nz-menu class="width-sm">
<div nz-menu-item routerLink="/pro/account/center">
<i nz-icon nzType="user" class="mr-sm"></i>
个人中心
</div>
<div nz-menu-item routerLink="/pro/account/settings">
<i nz-icon nzType="setting" class="mr-sm"></i>
个人设置
</div>
<div nz-menu-item routerLink="/exception/trigger">
<i nz-icon nzType="close-circle" class="mr-sm"></i>
触发错误
</div>
<li nz-menu-divider></li>
<div nz-menu-item (click)="logout()">
<i nz-icon nzType="logout" class="mr-sm"></i>
退出登录
</div>
</div>
</nz-dropdown-menu>

View File

@ -0,0 +1,29 @@
import { ChangeDetectionStrategy, Component, Inject, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { DA_SERVICE_TOKEN, ITokenService } from '@delon/auth';
import { SettingsService } from '@delon/theme';
@Component({
selector: 'layout-pro-user',
templateUrl: 'user.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class LayoutProWidgetUserComponent implements OnInit {
constructor(public settings: SettingsService, private router: Router, @Inject(DA_SERVICE_TOKEN) private tokenService: ITokenService) {}
ngOnInit(): void {
// mock
const token = this.tokenService.get() || {
token: 'nothing',
name: 'Admin',
avatar: './assets/logo-color.svg',
email: 'cipchk@qq.com'
};
this.tokenService.set(token);
}
logout(): void {
this.tokenService.clear();
this.router.navigateByUrl(this.tokenService.login_url!);
}
}

View File

@ -0,0 +1,19 @@
<!--Search-->
<layout-pro-search class="hidden-xs"></layout-pro-search>
<!--Link-->
<!-- <a nz-tooltip nzTooltipTitle="使用文档" nzTooltipPlacement="bottom" class="hidden-xs" target="_blank"
href="https://e.ng-alain.com/theme/pro" rel="noopener noreferrer" class="alain-pro__header-item">
<i nz-icon nzType="question-circle"></i>
</a> -->
<!--Quick chat status-->
<!-- <quick-chat-status class="hidden-xs"></quick-chat-status> -->
<!--Notify-->
<layout-pro-notify class="hidden-xs"></layout-pro-notify>
<!--RTL-->
<!-- <layout-pro-rtl></layout-pro-rtl> -->
<!--User-->
<layout-pro-user></layout-pro-user>
<!--Languages-->
<!-- <pro-langs></pro-langs> -->
<!--Quick panel-->
<!-- <layout-pro-quick class="hidden-xs"></layout-pro-quick> -->

View File

@ -0,0 +1,20 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { Router } from '@angular/router';
import { DA_SERVICE_TOKEN, ITokenService } from '@delon/auth';
@Component({
selector: '[layout-pro-header-widget]',
templateUrl: './widget.component.html',
host: {
'[class.alain-pro__header-right]': 'true'
},
changeDetection: ChangeDetectionStrategy.OnPush
})
export class LayoutProHeaderWidgetComponent {
constructor(private router: Router, @Inject(DA_SERVICE_TOKEN) private tokenService: ITokenService) {}
logout(): void {
this.tokenService.clear();
this.router.navigateByUrl(this.tokenService.login_url!);
}
}

View File

@ -0,0 +1 @@
[Document](https://e.ng-alain.com/theme/pro)

View File

@ -0,0 +1,67 @@
/* eslint-disable import/order */
// #region exports
export * from './pro.types';
export * from './pro.service';
export * from './pro.component';
// #endregion
// #region widgets
import { LayoutProFooterComponent } from './components/footer/footer.component';
import { LayoutProHeaderComponent } from './components/header/header.component';
import { LayoutProLogoComponent } from './components/logo/logo.component';
import { LayoutProMenuComponent } from './components/menu/menu.component';
import { LayoutProWidgetNotifyComponent } from './components/notify/notify.component';
import { LayoutProWidgetQuickChatStatusComponent } from './components/quick-chat/quick-chat-status.component';
import { LayoutProWidgetQuickChatComponent } from './components/quick-chat/quick-chat.component';
import { LayoutProWidgetQuickComponent } from './components/quick/quick.component';
import { LayoutProWidgetRTLComponent } from './components/rtl/rtl.component';
import { LayoutProWidgetSearchComponent } from './components/search/search.component';
import { LayoutProWidgetUserComponent } from './components/user/user.component';
import { LayoutProHeaderWidgetComponent } from './components/widget/widget.component';
import { LayoutProWidgetQuickPanelComponent } from './components/quick/quick-panel.component';
import { ProSettingDrawerComponent } from './setting-drawer/setting-drawer.component';
const PRO_WIDGETS = [
LayoutProHeaderWidgetComponent,
LayoutProWidgetNotifyComponent,
LayoutProWidgetSearchComponent,
LayoutProWidgetUserComponent,
LayoutProWidgetQuickComponent,
LayoutProWidgetQuickChatComponent,
LayoutProWidgetQuickChatStatusComponent,
LayoutProWidgetRTLComponent
];
// #endregion
// #region entry components
// #endregion
// #region components
import { LayoutProComponent } from './pro.component';
export const PRO_COMPONENTS: Array<Type<void>> = [
LayoutProComponent,
LayoutProMenuComponent,
LayoutProLogoComponent,
LayoutProHeaderComponent,
LayoutProFooterComponent,
LayoutProWidgetQuickPanelComponent,
ProSettingDrawerComponent,
...PRO_WIDGETS
];
// #endregion
// #region shared components
import { ProPageModule } from './shared/page';
import { Type } from '@angular/core';
export const PRO_SHARED_MODULES = [ProPageModule];
// #endregion

View File

@ -0,0 +1,40 @@
<ng-template #sideTpl>
<nz-sider [nzTrigger]="null" [nzCollapsible]="true" [nzCollapsed]="isMobile ? false : pro.collapsed"
[nzWidth]="pro.width" [nzCollapsedWidth]="pro.widthInCollapsed" class="alain-pro__sider"
[ngClass]="{ 'alain-pro__sider-fixed': pro.fixSiderbar }">
<layout-pro-logo class="alain-pro__sider-logo"></layout-pro-logo>
<div class="alain-pro__side-nav" style="width: 100%; padding: 16px 0">
<div class="alain-pro__side-nav-wrap" layout-pro-menu></div>
</div>
</nz-sider>
</ng-template>
<div class="ant-layout ant-layout-has-sider">
<ng-container *ngIf="pro.menu === 'side' || isMobile">
<nz-drawer *ngIf="isMobile" [nzWidth]="pro.width" nzWrapClassName="alain-pro__drawer" [nzVisible]="!pro.collapsed"
[nzClosable]="false" nzPlacement="left" (nzOnClose)="pro.setCollapsed(true)">
<ng-template nzDrawerContent>
<ng-template [ngTemplateOutlet]="sideTpl"></ng-template>
</ng-template>
</nz-drawer>
<ng-container *ngIf="!isMobile">
<ng-template [ngTemplateOutlet]="sideTpl"></ng-template>
</ng-container>
</ng-container>
<div class="ant-layout alain-pro__main" [ngStyle]="getLayoutStyle">
<layout-pro-header></layout-pro-header>
<!--
NOTICE: Route reuse strategy tag placeholder, please refer to: https://ng-alain.com/components/reuse-tab
- Not supported top header fixed mode
```html
<reuse-tab></reuse-tab>
```
-->
<div class="ant-layout-content alain-pro__body" [class.alain-pro__fetching]="isFetching"
[ngStyle]="getContentStyle">
<nz-spin class="alain-pro__fetching-icon" nzSpinning></nz-spin>
<router-outlet></router-outlet>
</div>
</div>
</div>
<ng-template #settingHost></ng-template>
<theme-btn></theme-btn>

View File

@ -0,0 +1,167 @@
import { BreakpointObserver, MediaMatcher } from '@angular/cdk/layout';
import { DOCUMENT } from '@angular/common';
import {
AfterViewInit,
Component,
ComponentFactoryResolver,
Inject,
OnDestroy,
OnInit,
Renderer2,
ViewChild,
ViewContainerRef
} from '@angular/core';
import { NavigationEnd, NavigationError, RouteConfigLoadStart, Router } from '@angular/router';
import { ReuseTabService } from '@delon/abc/reuse-tab';
import { RTL, RTLService } from '@delon/theme';
import { ScrollService, updateHostClass } from '@delon/util/browser';
import { environment } from '@env/environment';
import { NzMessageService } from 'ng-zorro-antd/message';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { BrandService } from './pro.service';
import { ProSettingDrawerComponent } from './setting-drawer/setting-drawer.component';
@Component({
selector: 'layout-pro',
templateUrl: './pro.component.html'
// NOTICE: If all pages using OnPush mode, you can turn it on and all `cdr.detectChanges()` codes
// changeDetection: ChangeDetectionStrategy.OnPush
})
export class LayoutProComponent implements OnInit, AfterViewInit, OnDestroy {
private destroy$ = new Subject<void>();
private queryCls?: string;
@ViewChild('settingHost', { read: ViewContainerRef, static: false }) private settingHost!: ViewContainerRef;
isFetching = false;
get isMobile(): boolean {
return this.pro.isMobile;
}
get getLayoutStyle(): { [key: string]: string } | null {
const { isMobile, fixSiderbar, collapsed, menu, width, widthInCollapsed } = this.pro;
if (fixSiderbar && menu !== 'top' && !isMobile) {
return {
[this.rtl.dir === RTL ? 'paddingRight' : 'paddingLeft']: `${collapsed ? widthInCollapsed : width}px`
};
}
return null;
}
get getContentStyle(): { [key: string]: string } {
const { fixedHeader, headerHeight } = this.pro;
return {
margin: '24px 24px 0',
'padding-top': `${fixedHeader ? headerHeight : 0}px`
};
}
private get body(): HTMLElement {
return this.doc.body;
}
constructor(
bm: BreakpointObserver,
mediaMatcher: MediaMatcher,
router: Router,
msg: NzMessageService,
scroll: ScrollService,
reuseTabSrv: ReuseTabService,
private resolver: ComponentFactoryResolver,
private renderer: Renderer2,
public pro: BrandService,
@Inject(DOCUMENT) private doc: any,
// private cdr: ChangeDetectorRef
private rtl: RTLService
) {
// scroll to top in change page
router.events.pipe(takeUntil(this.destroy$)).subscribe(evt => {
if (!this.isFetching && evt instanceof RouteConfigLoadStart) {
this.isFetching = true;
scroll.scrollToTop();
}
if (evt instanceof NavigationError) {
this.isFetching = false;
msg.error(`无法加载${evt.url}路由`, { nzDuration: 1000 * 3 });
return;
}
if (!(evt instanceof NavigationEnd)) {
return;
}
this.isFetching = false;
// If have already cached router, should be don't need scroll to top
if (!reuseTabSrv.exists(evt.url)) {
scroll.scrollToTop();
}
});
// media
const query: { [key: string]: string } = {
'screen-xs': '(max-width: 575px)',
'screen-sm': '(min-width: 576px) and (max-width: 767px)',
'screen-md': '(min-width: 768px) and (max-width: 991px)',
'screen-lg': '(min-width: 992px) and (max-width: 1199px)',
'screen-xl': '(min-width: 1200px)'
};
bm.observe([
'(min-width: 1200px)',
'(min-width: 992px) and (max-width: 1199px)',
'(min-width: 768px) and (max-width: 991px)',
'(min-width: 576px) and (max-width: 767px)',
'(max-width: 575px)'
]).subscribe(() => {
this.queryCls = Object.keys(query).find(key => mediaMatcher.matchMedia(query[key]).matches);
this.setClass();
});
}
private setClass(): void {
const { body, renderer, queryCls, pro } = this;
updateHostClass(body, renderer, {
['color-weak']: pro.layout.colorWeak,
[`layout-fixed`]: pro.layout.fixed,
[`aside-collapsed`]: pro.collapsed,
['alain-pro']: true,
[queryCls!]: true,
[`alain-pro__content-${pro.layout.contentWidth}`]: true,
[`alain-pro__fixed`]: pro.layout.fixedHeader,
[`alain-pro__wide`]: pro.isFixed,
[`alain-pro__dark`]: pro.theme === 'dark',
[`alain-pro__light`]: pro.theme === 'light',
[`alain-pro__menu-side`]: pro.isSideMenu,
[`alain-pro__menu-top`]: pro.isTopMenu
});
}
ngAfterViewInit(): void {
// Setting componet for only developer
if (!environment.production) {
setTimeout(() => {
const settingFactory = this.resolver.resolveComponentFactory(ProSettingDrawerComponent);
this.settingHost.createComponent(settingFactory);
}, 22);
}
}
ngOnInit(): void {
const { pro, destroy$ } = this;
pro.notify.pipe(takeUntil(destroy$)).subscribe(() => {
this.setClass();
});
}
ngOnDestroy(): void {
const { destroy$, body, pro } = this;
destroy$.next();
destroy$.complete();
body.classList.remove(
`alain-pro__content-${pro.layout.contentWidth}`,
`alain-pro__fixed`,
`alain-pro__wide`,
`alain-pro__dark`,
`alain-pro__light`
);
}
}

View File

@ -0,0 +1,139 @@
import { BreakpointObserver } from '@angular/cdk/layout';
import { Injectable } from '@angular/core';
import { Layout, SettingsService } from '@delon/theme';
import { environment } from '@env/environment';
import { BehaviorSubject, Observable } from 'rxjs';
import { ProLayout, ProLayoutContentWidth, ProLayoutMenu, ProLayoutTheme } from './pro.types';
@Injectable({ providedIn: 'root' })
export class BrandService {
private notify$ = new BehaviorSubject<string | null>(null);
private _isMobile = false;
// #region fields
get notify(): Observable<string | null> {
return this.notify$.asObservable();
}
/**
* Specify width of the sidebar, If you change it, muse be synchronize change less parameter:
* ```less
* @alain-pro-sider-menu-width: 256px;
* ```
*/
readonly width = 256;
/**
* Specify width of the sidebar after collapsed, If you change it, muse be synchronize change less parameter:
* ```less
* @menu-collapsed-width: 80px;
* ```
*/
readonly widthInCollapsed = 80;
/**
* Specify height of the header, If you change it, muse be synchronize change less parameter:
* ```less
* @alain-pro-header-height: 64px;
* ```
*/
readonly headerHeight = 64;
/**
* Specify distance from top for automatically hidden header
*/
readonly autoHideHeaderTop = 300;
get isMobile(): boolean {
return this._isMobile;
}
get layout(): ProLayout {
return this.settings.layout as ProLayout;
}
get collapsed(): boolean {
return this.layout.collapsed;
}
get theme(): ProLayoutTheme {
return this.layout.theme;
}
get menu(): ProLayoutMenu {
return this.layout.menu;
}
get contentWidth(): ProLayoutContentWidth {
return this.layout.contentWidth;
}
get fixedHeader(): boolean {
return this.layout.fixedHeader;
}
get autoHideHeader(): boolean {
return this.layout.autoHideHeader;
}
get fixSiderbar(): boolean {
return this.layout.fixSiderbar;
}
get onlyIcon(): boolean {
return this.menu === 'side' ? false : this.layout.onlyIcon;
}
/** Whether the top menu */
get isTopMenu(): boolean {
return this.menu === 'top' && !this.isMobile;
}
/** Whether the side menu */
get isSideMenu(): boolean {
return this.menu === 'side' && !this.isMobile;
}
/** Whether the fixed content */
get isFixed(): boolean {
return this.contentWidth === 'fixed';
}
// #endregion
constructor(bm: BreakpointObserver, private settings: SettingsService) {
// fix layout data
settings.setLayout({
theme: 'dark',
menu: 'side',
contentWidth: 'fluid',
fixedHeader: false,
autoHideHeader: false,
fixSiderbar: false,
onlyIcon: true,
...(environment as any).pro,
...settings.layout // Browser cache
});
const mobileMedia = 'only screen and (max-width: 767.99px)';
bm.observe(mobileMedia).subscribe(state => this.checkMedia(state.matches));
this.checkMedia(bm.isMatched(mobileMedia));
}
private checkMedia(value: boolean): void {
this._isMobile = value;
this.layout.collapsed = this._isMobile;
this.notify$.next('mobile');
}
setLayout(name: string | Layout, value?: any): void {
this.settings.setLayout(name, value);
this.notify$.next('layout');
}
setCollapsed(status?: boolean): void {
this.setLayout('collapsed', typeof status !== 'undefined' ? status : !this.collapsed);
}
}

View File

@ -0,0 +1,43 @@
import { Layout, MenuInner } from '@delon/theme';
export type ProLayoutTheme = 'light' | 'dark';
export type ProLayoutMenu = 'side' | 'top';
export type ProLayoutContentWidth = 'fluid' | 'fixed';
export interface ProLayout extends Layout {
theme: ProLayoutTheme;
/**
* menu position
*/
menu: ProLayoutMenu;
/**
* layout of content, only works when menu is top
*/
contentWidth: ProLayoutContentWidth;
/**
* sticky header
*/
fixedHeader: boolean;
/**
* auto hide header
*/
autoHideHeader: boolean;
/**
* sticky siderbar
*/
fixSiderbar: boolean;
/**
* Only icon of menu
* Limited to a temporary solution [#2183](https://github.com/NG-ZORRO/ng-zorro-antd/issues/2183)
*/
onlyIcon: boolean;
/**
* Color weak
*/
colorWeak: boolean;
}
export interface ProMenu extends MenuInner {
_parent?: ProMenu | null;
children?: ProMenu[];
}

View File

@ -0,0 +1,83 @@
<nz-drawer [(nzVisible)]="collapse" [nzPlacement]="dir === 'rtl' ? 'left' : 'right'" [nzWidth]="300"
(nzOnClose)="toggle()">
<div *nzDrawerContent class="setting-drawer__content">
<div class="setting-drawer__body">
<h3 class="setting-drawer__title">整体风格设置</h3>
<div class="setting-drawer__blockChecbox">
<div *ngFor="let t of themes" class="setting-drawer__blockChecbox-item" (click)="setLayout('theme', t.key)"
[nz-tooltip]="t.title">
<img src="{{ t.img }}" alt="{{ t.key }}" />
<div *ngIf="layout.theme === t.key" class="setting-drawer__blockChecbox-selectIcon">
<i nz-icon nzType="check"></i>
</div>
</div>
</div>
</div>
<div class="setting-drawer__body setting-drawer__theme">
<h3 class="setting-drawer__title">主题色</h3>
<span *ngFor="let c of colors" (click)="changeColor(c.color)" nz-tooltip="c.key" class="setting-drawer__theme-tag"
[ngStyle]="{ 'background-color': c.color }">
<i *ngIf="color === c.color" nz-icon nzType="check"></i>
</span>
</div>
<nz-divider></nz-divider>
<div class="setting-drawer__body">
<h3 class="setting-drawer__title">导航模式</h3>
<div class="setting-drawer__blockChecbox">
<div *ngFor="let t of menuModes" class="setting-drawer__blockChecbox-item" (click)="setLayout('menu', t.key)"
nz-tooltip="{{ t.title }}">
<img src="{{ t.img }}" alt="{{ t.key }}" />
<div *ngIf="layout.menu === t.key" class="setting-drawer__blockChecbox-selectIcon">
<i nz-icon nzType="check"></i>
</div>
</div>
</div>
<div class="setting-drawer__body-item">
内容区域宽度
<nz-select [(ngModel)]="layout.contentWidth" (ngModelChange)="setLayout('contentWidth', layout.contentWidth)"
nzSize="small">
<nz-option *ngFor="let i of contentWidths" [nzLabel]="i.title" [nzValue]="i.key" [nzDisabled]="i.disabled">
</nz-option>
</nz-select>
</div>
<div class="setting-drawer__body-item">
固定 Header
<nz-switch nzSize="small" [(ngModel)]="layout.fixedHeader"
(ngModelChange)="setLayout('fixedHeader', layout.fixedHeader)"></nz-switch>
</div>
<div class="setting-drawer__body-item" nz-tooltip="{{ !brand.fixedHeader ? '固定 Header 时可配置' : '' }}"
nzTooltipPlacement="left">
<span [style.opacity]="!brand.fixedHeader ? 0.5 : 1">下滑时隐藏 Header</span>
<nz-switch [nzDisabled]="!brand.fixedHeader" nzSize="small" [(ngModel)]="layout.autoHideHeader"
(ngModelChange)="setLayout('autoHideHeader', layout.autoHideHeader)"></nz-switch>
</div>
<div class="setting-drawer__body-item" nz-tooltip="{{ brand.menu === 'top' ? '侧边菜单布局时可配置' : '' }}"
nzTooltipPlacement="left">
<span [style.opacity]="brand.menu === 'top' ? 0.5 : 1">固定侧边菜单</span>
<nz-switch [nzDisabled]="brand.menu === 'top'" nzSize="small" [(ngModel)]="layout.fixSiderbar"
(ngModelChange)="setLayout('fixSiderbar', layout.fixSiderbar)"></nz-switch>
</div>
<div class="setting-drawer__body-item" nz-tooltip="{{ brand.menu === 'top' ? '' : '顶部菜单布局时可配置' }}"
nzTooltipPlacement="left">
<span [style.opacity]="brand.menu !== 'top' ? 0.5 : 1">只显示图标</span>
<nz-switch [nzDisabled]="brand.menu !== 'top'" nzSize="small" [(ngModel)]="layout.onlyIcon"
(ngModelChange)="setLayout('onlyIcon', layout.onlyIcon)"></nz-switch>
</div>
</div>
<nz-divider></nz-divider>
<div class="setting-drawer__body">
<h3 class="setting-drawer__title">其他设置</h3>
<div class="setting-drawer__body-item">
色弱模式
<nz-switch nzSize="small" [(ngModel)]="layout.colorWeak"
(ngModelChange)="setLayout('colorWeak', layout.colorWeak)"></nz-switch>
</div>
</div>
<nz-divider></nz-divider>
<button (click)="copy()" type="button" nz-button nzBlock>拷贝设置</button>
<nz-alert class="mt-md" nzType="warning" nzMessage="配置栏只在开发环境用于预览,生产环境不会展现,请拷贝后手动修改配置文件"></nz-alert>
</div>
</nz-drawer>
<div class="setting-drawer__handle" [ngClass]="{ 'setting-drawer__handle-opened': collapse }" (click)="toggle()">
<i nz-icon [nzType]="!collapse ? 'setting' : 'close'" class="setting-drawer__handle-icon"></i>
</div>

View File

@ -0,0 +1,240 @@
import { Direction, Directionality } from '@angular/cdk/bidi';
import { DOCUMENT } from '@angular/common';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, NgZone, OnDestroy, OnInit, Optional } from '@angular/core';
import { copy, LazyService } from '@delon/util';
import { NzMessageService } from 'ng-zorro-antd/message';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { BrandService } from '../pro.service';
import { ProLayout } from '../pro.types';
@Component({
selector: 'pro-setting-drawer',
templateUrl: './setting-drawer.component.html',
preserveWhitespaces: false,
host: {
'[class.setting-drawer]': 'true',
'[class.setting-drawer-rtl]': `dir === 'rtl'`
},
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProSettingDrawerComponent implements OnInit, OnDestroy {
private loadedLess = false;
private destroy$ = new Subject<void>();
get layout(): ProLayout {
return this.brand.layout;
}
collapse = false;
dir: Direction = 'ltr';
themes = [
{
key: 'dark',
title: '暗色菜单风格',
img: 'https://gw.alipayobjects.com/zos/rmsportal/LCkqqYNmvBEbokSDscrm.svg'
},
{
key: 'light',
title: '亮色菜单风格',
img: 'https://gw.alipayobjects.com/zos/rmsportal/jpRkZQMyYRryryPNtyIC.svg'
}
];
color = '#2F54EB';
colors = [
{
key: '薄暮',
color: '#F5222D'
},
{
key: '火山',
color: '#FA541C'
},
{
key: '日暮',
color: '#FAAD14'
},
{
key: '明青',
color: '#13C2C2'
},
{
key: '极光绿',
color: '#52C41A'
},
{
key: '拂晓蓝(默认)',
color: '#1890ff'
},
{
key: '极客蓝',
color: '#2F54EB'
},
{
key: '酱紫',
color: '#722ED1'
}
];
menuModes = [
{
key: 'side',
title: '侧边菜单布局',
img: 'https://gw.alipayobjects.com/zos/rmsportal/JopDzEhOqwOjeNTXkoje.svg'
},
{
key: 'top',
title: '顶部菜单布局',
img: 'https://gw.alipayobjects.com/zos/rmsportal/KDNDBbriJhLwuqMoxcAr.svg'
}
];
contentWidths = [
{
key: 'fixed',
title: '定宽',
disabled: false
},
{
key: 'fluid',
title: '流式',
disabled: false
}
];
constructor(
public brand: BrandService,
private cdr: ChangeDetectorRef,
private msg: NzMessageService,
private lazy: LazyService,
private zone: NgZone,
@Inject(DOCUMENT) private doc: any,
@Optional() private directionality: Directionality
) {
this.setLayout('menu', this.brand.menu, false);
}
ngOnInit(): void {
this.dir = this.directionality.value;
this.directionality.change?.pipe(takeUntil(this.destroy$)).subscribe((direction: Direction) => {
this.dir = direction;
this.cdr.detectChanges();
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
private loadLess(): Promise<void> {
if (this.loadedLess) {
return Promise.resolve();
}
return this.lazy
.loadStyle('./assets/color.less', 'stylesheet/less')
.then(() => {
const lessConfigNode = this.doc.createElement('script');
lessConfigNode.innerHTML = `
window.less = {
async: true,
env: 'production',
javascriptEnabled: true
};
`;
this.doc.body.appendChild(lessConfigNode);
})
.then(() => this.lazy.loadScript('https://gw.alipayobjects.com/os/lib/less.js/3.8.1/less.min.js'))
.then(() => {
this.loadedLess = true;
});
}
private runLess(): void {
const { color, zone, msg, cdr: cd } = this;
const msgId = msg.loading(`正在编译主题!`, { nzDuration: 0 }).messageId;
setTimeout(() => {
zone.runOutsideAngular(() => {
this.loadLess().then(() => {
(window as any).less
.modifyVars({
[`@primary-color`]: color
})
.then(() => {
msg.success('成功');
msg.remove(msgId);
zone.run(() => cd.detectChanges());
});
});
});
}, 200);
}
toggle(): void {
this.collapse = !this.collapse;
}
changeColor(color: string): void {
this.color = color;
this.runLess();
}
setLayout(name: string, value: any, cd: boolean = true): void {
switch (name) {
case 'menu':
const isTop = value === 'top';
this.contentWidths.find(w => w.key === 'fixed')!.disabled = !isTop;
const newLayout = {
...this.brand.layout,
contentWidth: isTop ? 'fixed' : 'fluid',
onlyIcon: isTop,
collapsed: isTop && !this.brand.isMobile ? false : this.brand.layout.collapsed
};
this.brand.setLayout(newLayout);
break;
case 'fixedHeader':
this.brand.setLayout('autoHideHeader', false);
break;
default:
break;
}
this.brand.setLayout(name, value);
if (cd) {
setTimeout(() => {
// Re-render G2 muse be trigger window resize
window.dispatchEvent(new Event('resize'));
this.cdr.markForCheck();
});
}
}
copy(): void {
const { color, layout } = this;
const vars: { [key: string]: string } = {
[`@primary-color`]: color
};
const colorVars = Object.keys(vars)
.map(key => `${key}: ${vars[key]};`)
.join('\n');
const layoutVars = Object.keys(layout)
.filter(
key => ~['theme', 'menu', 'contentWidth', 'fixedHeader', 'autoHideHeader', 'fixSiderbar', 'colorWeak', 'onlyIcon'].indexOf(key)
)
.map(key => {
const value = layout[key];
if (typeof value === 'boolean') {
return ` ${key}: ${value},`;
} else {
return ` ${key}: '${value}',`;
}
})
.join('\n');
copy(
`在 [src/styles/theme.less] 配置以下:\n{{colorVars}}\n\n在 [src/environments/*] 的 pro 配置以下:\nexport const environment = {\n ...\n pro: {\n{{layoutVars}}\n }\n}`
);
this.msg.success(`拷贝成功,请根据剪切板内容进行替换`);
}
}

View File

@ -0,0 +1,3 @@
export * from './page-grid.component';
export * from './page-header-wrapper.component';
export * from './page.module';

View File

@ -0,0 +1,4 @@
<nz-spin [nzSpinning]="loading">
<div *ngIf="loading" class="brand-page-loading"></div>
<ng-content></ng-content>
</nz-spin>

View File

@ -0,0 +1,80 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
Inject,
Input,
OnDestroy,
Optional,
Renderer2
} from '@angular/core';
import { ReuseTabService } from '@delon/abc/reuse-tab';
import { TitleService } from '@delon/theme';
import { BooleanInput, InputBoolean } from '@delon/util';
import { NzSafeAny } from 'ng-zorro-antd/core/types';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { BrandService } from '../../pro.service';
@Component({
selector: 'page-grid',
templateUrl: './page-grid.component.html',
host: {
'[class.alain-pro__page-grid]': 'true',
'[class.alain-pro__page-grid-no-spacing]': 'noSpacing',
'[class.alain-pro__page-grid-wide]': 'isFixed'
},
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProPageGridComponent implements AfterViewInit, OnDestroy {
static ngAcceptInputType_loading: BooleanInput;
static ngAcceptInputType_noSpacing: BooleanInput;
private unsubscribe$ = new Subject<void>();
@Input() @InputBoolean() loading = false;
@Input() @InputBoolean() noSpacing = false;
@Input() style: NzSafeAny;
@Input()
set title(value: string) {
if (value) {
if (this.titleSrv) {
this.titleSrv.setTitle(value);
}
if (this.reuseSrv) {
this.reuseSrv.title = value;
}
}
}
get isFixed(): boolean {
return this.pro.isFixed;
}
constructor(
private el: ElementRef,
private rend: Renderer2,
private pro: BrandService,
@Optional() @Inject(TitleService) private titleSrv: TitleService,
@Optional() @Inject(ReuseTabService) private reuseSrv: ReuseTabService,
private cdr: ChangeDetectorRef
) {}
ngAfterViewInit(): void {
if (this.style) {
Object.keys(this.style).forEach((key: string) => {
this.rend.setStyle(this.el.nativeElement, key, this.style[key]);
});
}
this.pro.notify.pipe(takeUntil(this.unsubscribe$)).subscribe(() => this.cdr.markForCheck());
}
ngOnDestroy(): void {
const { unsubscribe$ } = this;
unsubscribe$.next();
unsubscribe$.complete();
}
}

View File

@ -0,0 +1,26 @@
<nz-spin [nzSpinning]="loading">
<ng-template [ngTemplateOutlet]="top"></ng-template>
<page-header
[wide]="pro.isFixed"
[fixed]="false"
[title]="title"
[home]="home"
[homeLink]="homeLink"
[homeI18n]="homeI18n"
[autoBreadcrumb]="autoBreadcrumb"
[autoTitle]="autoTitle"
[syncTitle]="syncTitle"
[action]="action"
[breadcrumb]="breadcrumb"
[logo]="logo"
[content]="content"
[extra]="extra"
[tab]="tab"
><ng-template [ngTemplateOutlet]="phContent"></ng-template
></page-header>
<div class="alain-pro__page-header-content">
<page-grid [noSpacing]="noSpacing" [style]="style">
<ng-content></ng-content>
</page-grid>
</div>
</nz-spin>

View File

@ -0,0 +1,74 @@
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnDestroy, TemplateRef } from '@angular/core';
import { AlainConfigService, BooleanInput, InputBoolean } from '@delon/util';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { BrandService } from '../../pro.service';
@Component({
selector: 'page-header-wrapper',
templateUrl: './page-header-wrapper.component.html',
host: {
'[class.alain-pro__page-header-wrapper]': 'true'
},
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProPageHeaderWrapperComponent implements AfterViewInit, OnDestroy {
static ngAcceptInputType_loading: BooleanInput;
static ngAcceptInputType_autoBreadcrumb: BooleanInput;
static ngAcceptInputType_autoTitle: BooleanInput;
static ngAcceptInputType_syncTitle: BooleanInput;
static ngAcceptInputType_noSpacing: BooleanInput;
private unsubscribe$ = new Subject<void>();
// #region page-header fields
@Input() title!: string | null | TemplateRef<void>;
@Input() @InputBoolean() loading = false;
@Input() home!: string;
@Input() homeLink!: string;
@Input() homeI18n!: string;
/**
* 自动生成导航,以当前路由从主菜单中定位
*/
@Input() @InputBoolean() autoBreadcrumb = true;
/**
* 自动生成标题,以当前路由从主菜单中定位
*/
@Input() @InputBoolean() autoTitle = true;
/**
* 是否自动将标题同步至 `TitleService`、`ReuseService` 下,仅 `title` 为 `string` 类型时有效
*/
@Input() @InputBoolean() syncTitle = true;
@Input() breadcrumb!: TemplateRef<void>;
@Input() logo!: TemplateRef<void>;
@Input() action!: TemplateRef<void>;
@Input() content!: TemplateRef<void>;
@Input() extra!: TemplateRef<void>;
@Input() tab!: TemplateRef<void>;
@Input() phContent!: TemplateRef<void>;
// #endregion
// #region fields
@Input() top!: TemplateRef<void>;
@Input() @InputBoolean() noSpacing = false;
@Input() style?: {};
// #endregion
constructor(public pro: BrandService, cog: AlainConfigService, private cdr: ChangeDetectorRef) {
cog.attach(this, 'pageHeader', { syncTitle: true });
}
ngAfterViewInit(): void {
this.pro.notify.pipe(takeUntil(this.unsubscribe$)).subscribe(() => this.cdr.markForCheck());
}
ngOnDestroy(): void {
const { unsubscribe$ } = this;
unsubscribe$.next();
unsubscribe$.complete();
}
}

View File

@ -0,0 +1,16 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { PageHeaderModule } from '@delon/abc/page-header';
import { NzSpinModule } from 'ng-zorro-antd/spin';
import { ProPageGridComponent } from './page-grid.component';
import { ProPageHeaderWrapperComponent } from './page-header-wrapper.component';
const COMPONENTS = [ProPageGridComponent, ProPageHeaderWrapperComponent];
@NgModule({
imports: [CommonModule, NzSpinModule, PageHeaderModule],
declarations: COMPONENTS,
exports: COMPONENTS
})
export class ProPageModule {}

View File

@ -0,0 +1,58 @@
@{alain-pro-prefix} {
&__main {
min-height: 100vh;
background-color: @alain-pro-main-bg;
> .ant-layout-header {
height: @alain-pro-header-height;
line-height: @alain-pro-header-height;
}
.router-ant();
}
&__page {
&-header {
&-wrapper {
position: relative;
display: block;
margin: -@alain-pro-content-margin -@alain-pro-content-margin 0;
}
&-content {
margin: @alain-pro-content-margin @alain-pro-content-margin 0;
}
}
&-grid {
display: block;
width: 100%;
height: 100%;
min-height: 100%;
transition: 0.3s;
&-wide {
max-width: @alain-pro-wide;
margin: 0 auto;
}
&-no-spacing {
width: initial;
margin: -@alain-pro-content-margin -@alain-pro-content-margin 0;
}
}
}
}
@{alain-pro-prefix}__fetching {
&-icon {
display: none;
}
& {
@{alain-pro-prefix}__fetching-icon {
display: block;
}
}
}
@media screen and (max-width: @mobile-max) {
@{alain-pro-prefix}__page-header-content {
margin: @alain-pro-content-margin 10px 0;
}
}

View File

@ -0,0 +1,3 @@
@{alain-pro-prefix}__footer {
padding: 0;
}

View File

@ -0,0 +1,104 @@
@header-prefix: ~'@{alain-pro-prefix}__header';
@{header-prefix} {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
height: @alain-pro-header-height;
padding: 0 12px 0 0;
background: @alain-pro-header-bg;
box-shadow: @alain-pro-header-box-shadow;
&-logo {
padding: 0 24px;
}
&-right {
display: flex;
align-items: center;
justify-items: center;
}
&-item {
position: relative;
display: flex;
align-items: center;
justify-items: center;
height: @alain-pro-header-height;
padding: 0 12px;
line-height: @alain-pro-header-height;
cursor: pointer;
transition: all 0.3s;
> i,
&-icon {
// fix dropdown
font-size: @alain-pro-header-widgets-icon-fs !important;
transform: none !important;
}
&,
&-icon {
color: @alain-pro-header-color;
}
&:hover {
background: @alain-pro-header-hover-bg;
&,
@{header-prefix}-item-icon {
color: @alain-pro-header-hover-color;
}
}
}
&-trigger {
padding: 0 24px;
@{header-prefix}-item-icon {
font-size: 20px !important;
}
}
&-search {
&:hover {
background: transparent;
}
}
&-fixed {
position: fixed;
top: 0;
right: 0;
z-index: 9;
width: 100%;
transition: width 0.2s;
}
&-hide {
opacity: 0;
transition: opacity 0.2s;
}
}
@media only screen and (max-width: @mobile-max) {
@{header-prefix} {
&-name {
display: none;
}
&-trigger {
padding: 0 12px;
}
&-logo {
position: relative;
padding-right: 12px;
padding-left: 12px;
}
}
}
layout-pro-header {
z-index: 1;
}
.layout-pro-header-rtl-mixin(@enabled) when(@enabled=true) {
[dir='rtl'] {
@{header-prefix} {
&-fixed {
right: inherit;
left: 0;
}
}
}
}
.layout-pro-header-rtl-mixin(@rtl-enabled);

View File

@ -0,0 +1,68 @@
@{alain-pro-prefix} {
&__menu {
display: block;
&-item {
&--disabled {
pointer-events: none;
}
}
&-only-icon {
@{alain-pro-prefix}__menu-item {
padding-right: 8px !important;
padding-left: 8px !important;
&:first-child {
padding-left: 0;
}
}
@{alain-pro-prefix}__menu-icon {
margin-right: 0;
font-size: @alain-pro-top-nav-only-icon-fs;
}
}
&-title {
position: relative;
&-badge {
display: flex;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
font-size: 12px;
line-height: 18px;
background: @alain-pro-header-title-badge-bg;
border-radius: 50%;
> em {
color: @alain-pro-header-title-badge-color;
font-style: normal;
}
}
}
&-img {
width: @alain-pro-sider-menu-img-wh !important;
height: @alain-pro-sider-menu-img-wh !important;
}
}
&__side-nav {
@{alain-pro-prefix}__menu {
&-title {
display: flex;
align-items: center;
&-text {
flex: 1;
}
}
}
.@{ant-prefix}-menu-inline-collapsed {
@{alain-pro-prefix}__menu-title-badge {
position: absolute;
top: 0;
right: -16px;
width: 8px;
height: 8px;
> em {
display: none;
}
}
}
}
}

View File

@ -0,0 +1,148 @@
@sider-prefix: ~'@{alain-pro-prefix}__sider';
@{sider-prefix} {
position: relative;
z-index: 10;
display: block;
min-height: 100vh;
overflow: hidden;
box-shadow: 2px 0 6px rgba(0, 21, 41, 0.35);
&-logo {
position: relative;
display: block;
height: @alain-pro-header-height;
padding-left: ((@menu-collapsed-width - 32px) / 2);
overflow: hidden;
line-height: @alain-pro-header-height;
background: @alain-pro-header-logo-bg;
transition: all 0.3s;
img {
display: inline-block;
height: 32px;
vertical-align: middle;
}
h1 {
display: inline-block;
margin: 0 0 0 12px;
color: white;
font-weight: 600;
font-size: @alain-pro-logo-font-size;
font-family: @alain-pro-logo-font-family;
vertical-align: middle;
transition: all 0.3s;
}
}
&-fixed {
position: fixed;
top: 0;
left: 0;
@{alain-pro-prefix}__side-nav-wrap {
height: ~'calc(100vh - @{alain-pro-header-height})';
padding-bottom: 24px;
overflow-y: scroll;
}
}
}
// 小屏幕 drawer 配置信息
@{alain-pro-prefix}__drawer {
.@{ant-prefix}-drawer {
&-wrapper-body {
height: 100%;
overflow: auto;
}
&-body {
height: 100vh;
padding: 0;
overflow-x: hidden;
}
}
}
// 当缩进时隐藏站点名称
@{aside-collapsed-prefix} {
@{sider-prefix}-logo {
display: flex;
justify-content: center;
padding: 0;
h1 {
display: none;
}
}
}
@{alain-pro-prefix}__light {
@{sider-prefix} {
background-color: @alain-pro-light-color;
box-shadow: @alain-pro-light-slider-shadow;
&-logo {
background: @alain-pro-light-color;
box-shadow: 1px 1px 0 0 @border-color-split;
h1 {
color: @primary-color;
}
}
.@{ant-prefix}-menu-light {
border-right-color: transparent;
}
}
}
.alain-pro-sider-fixed-scroll-mixin(@mode) when(@mode='show') {
.scrollbar-mixin(~'@{ider-prefix}-fixed @{alain-pro-prefix}__side-nav-wrap');
}
.alain-pro-sider-fixed-scroll-mixin(@mode) when(@mode='hide') {
@{sider-prefix}-fixed @{alain-pro-prefix}__side-nav-wrap {
-webkit-overflow-scrolling: touch;
// IE
-ms-scroll-chaining: chained;
-ms-content-zooming: zoom;
-ms-scroll-rails: none;
-ms-content-zoom-limit-min: 100%;
-ms-content-zoom-limit-max: 500%;
-ms-scroll-snap-type: proximity;
-ms-scroll-snap-points-x: snaplist(100%, 200%, 300%, 400%, 500%);
-ms-overflow-style: none;
// Firefox
scrollbar-width: none;
// Chrome
&::-webkit-scrollbar {
width: @alain-pro-sider-scrollbar-height;
height: @alain-pro-sider-scrollbar-width;
}
&::-webkit-scrollbar-track {
-webkit-box-shadow: inset 0 0 @alain-pro-sider-scrollbar-width @alain-pro-sider-scrollbar-track-color;
}
&::-webkit-scrollbar-thumb {
background-color: @alain-pro-sider-scrollbar-thumb-color;
}
}
}
.alain-pro-sider-fixed-scroll-mixin(@alain-pro-sider-fixed-scroll-mode);
.layout-pro-sider-rtl-mixin(@enabled) when(@enabled=true) {
[dir='rtl'] {
@{sider-prefix} {
&-logo {
padding-right: ((@menu-collapsed-width - 32px) / 2);
padding-left: 0;
h1 {
margin-right: 12px;
margin-left: 0;
}
}
&-fixed {
right: 0;
left: inherit;
}
}
@{aside-collapsed-prefix} {
@{sider-prefix}-logo {
padding: 0;
}
}
}
}
.layout-pro-sider-rtl-mixin(@rtl-enabled);

View File

@ -0,0 +1,101 @@
@top-nav-prefix: ~'@{alain-pro-prefix}__top-nav';
@{top-nav-prefix} {
position: relative;
width: 100%;
height: @alain-pro-header-height;
padding: 0 12px 0 0;
box-shadow: @alain-pro-header-box-shadow;
transition: background 0.3s, width 0.2s;
@{alain-pro-prefix}__menu {
.@{ant-prefix}-menu {
display: flex;
align-items: center;
height: @alain-pro-header-height;
border: none;
}
&-wrap {
flex: 1;
padding-right: 8px;
}
&-item {
height: 100%;
.@{ant-prefix}-menu-submenu-title {
height: 100%;
padding: 0 12px;
}
}
}
&-main {
display: flex;
height: @alain-pro-header-height;
padding-left: 24px;
&-wide {
max-width: @alain-pro-wide;
margin: auto;
padding-left: 4px;
}
&-left {
display: flex;
flex: 1;
}
}
&-logo {
width: 165px;
h1 {
margin: 0 0 0 12px;
color: #fff;
font-size: 16px;
}
}
@{alain-pro-prefix}__menu-title-badge {
position: absolute;
top: -16px;
right: -16px;
}
}
@{alain-pro-prefix} {
&__dark {
@{top-nav-prefix} {
@{alain-pro-prefix}__header-item {
&,
&-icon {
color: @menu-dark-color;
}
&:hover,
.@{ant-prefix}-popover-open {
background: @menu-dark-item-active-bg;
@{alain-pro-prefix}__header-item {
&,
&-icon {
color: @menu-dark-highlight-color;
}
}
}
}
}
}
&__light {
@{top-nav-prefix} {
background-color: #fff;
h1 {
color: #002140;
}
}
}
}
.layout-pro-top-nav-rtl-mixin(@enabled) when(@enabled=true) {
[dir='rtl'] {
@{top-nav-prefix} {
&-logo {
h1 {
margin-right: 12px;
margin-left: 0;
}
}
}
}
}
.layout-pro-top-nav-rtl-mixin(@rtl-enabled);

View File

@ -0,0 +1,6 @@
@layout-body-background: #f8f8f8;
// Remoed card transition
.@{ant-prefix}-card {
transition: none;
}

View File

@ -0,0 +1,5 @@
.btn-flat {
background: transparent !important;
border-color: transparent !important;
box-shadow: none !important;
}

View File

@ -0,0 +1,55 @@
@{header-prefix}-item {
.@{ant-prefix}-badge-count {
position: absolute;
top: 24px;
right: 12px;
}
}
// full-content component
@{full-content-prefix} {
&__opened {
layout-pro-header,
@{alain-pro-prefix}__sider,
reuse-tab {
display: none !important;
}
}
&__hidden-title {
page-header {
display: none !important;
}
}
}
// footer-toolbar component
@{footer-toolbar-prefix} {
z-index: 99;
width: auto;
&__body {
@{alain-pro-prefix}__body {
margin-bottom: @layout-gutter + @footer-toolbar-height !important;
}
}
}
@{alain-pro-prefix}__menu-side {
@{footer-toolbar-prefix} {
left: @alain-pro-sider-menu-width;
}
}
@{alain-pro-prefix}__menu-top {
@{footer-toolbar-prefix} {
left: 0;
}
}
@{aside-collapsed-prefix} {
@{footer-toolbar-prefix} {
left: @menu-collapsed-width;
}
}
@{page-header-prefix} {
padding-right: @alain-pro-content-margin;
padding-left: @alain-pro-content-margin;
}

View File

@ -0,0 +1,3 @@
.@{ant-prefix}-dropdown-menu-item {
outline: none;
}

View File

@ -0,0 +1,3 @@
@page {
size: a3;
}

View File

@ -0,0 +1,8 @@
@import './_antd.less';
@import './_delon.less';
// components
@import './_btn.less';
@import './_menu.less';
@import './_print.less';

View File

@ -0,0 +1,8 @@
@import './_menu.less';
@import './_content.less';
@import './_header.less';
@import './_top-nav.less';
@import './_sider.less';
@import './_footer.less';
@import './fix/index.less';

View File

@ -0,0 +1,4 @@
@import './theme-default.less';
@import './app/index.less';
@import './widgets/index.less';

View File

@ -0,0 +1,5 @@
@import '~@delon/theme/theme-compact.less';
@import './theme-default.less';
@alain-pro-header-height: 56px;
@alain-pro-header-widgets-icon-fs: 14px;

View File

@ -0,0 +1,9 @@
@import '~@delon/theme/theme-dark.less';
@import './theme-default.less';
@alain-pro-header-bg: @component-background;
@alain-pro-header-logo-bg: @popover-background;
@alain-pro-header-box-shadow: @shadow-1-down; // 0 1px 4px rgba(0, 21, 41, 0.08);
@alain-pro-header-color: @text-color;
@alain-pro-header-hover-color: #fff;
@alain-pro-header-hover-bg: rgba(0, 0, 0, 0.025);

View File

@ -0,0 +1,53 @@
@import '~@delon/theme/theme-default.less';
// Optimization for NG-ALAIN theme system
// 针对 NG-ALAIN 主题系统的优化
@badge-enabled: false;
// PRO
@alain-pro-prefix: ~'.alain-pro';
@aside-collapsed-prefix: ~'.aside-collapsed';
@alain-pro-zindex: @zindex-base;
@alain-pro-ease: cubic-bezier(0.25, 0, 0.15, 1);
@alain-pro-header-height: 64px;
@alain-pro-header-bg: #fff;
@alain-pro-header-logo-bg: #002140;
@alain-pro-header-box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
@alain-pro-header-color: rgba(0, 0, 0, 0.65);
@alain-pro-header-hover-color: #000;
@alain-pro-header-hover-bg: rgba(0, 0, 0, 0.025);
@alain-pro-header-search-width: 210px;
@alain-pro-header-widgets-icon-fs: 16px;
@alain-pro-header-title-badge-bg: @primary-color;
@alain-pro-header-title-badge-color: #fff;
@alain-pro-main-bg: @layout-body-background;
@alain-pro-top-nav-only-icon-fs: 16px;
@alain-pro-light-color: #fff;
@alain-pro-light-slider-shadow: 2px 0 8px 0 rgba(29, 35, 41, 0.05);
@alain-pro-logo-font-size: 20px;
@alain-pro-logo-font-family: 'Myriad Pro', 'Helvetica Neue', Arial, Helvetica, sans-serif;
@alain-pro-content-margin: 24px;
@alain-pro-wide: 1200px;
@alain-pro-sider-menu-width: 256px;
@alain-pro-sider-menu-img-wh: 14px;
/**
* 侧边栏固定时滚动条显示风格:
* - hide: 隐藏滚动条(默认)
* - show: 显示美化后滚动条
*/
@alain-pro-sider-fixed-scroll-mode: 'hide';
@alain-pro-sider-scrollbar-width: 0;
@alain-pro-sider-scrollbar-height: 0;
@alain-pro-sider-scrollbar-track-color: transparent;
@alain-pro-sider-scrollbar-thumb-color: transparent;
@brand-bordered-color: rgba(24, 28, 33, 0.06);
@brand-scroll-width: 20px;

View File

@ -0,0 +1,34 @@
.brand {
// 让 `nz-row` & `nz-col` 包含有边框效果
&-bordered {
overflow: hidden;
border: 1px solid @brand-bordered-color;
> [class*='ant-col-']::before,
> [class*='ant-col-']::after {
position: absolute;
display: block;
content: '';
}
> [class*='ant-col-']::before {
right: 0;
bottom: -1px;
left: 0;
height: 0;
border-top: 1px solid @brand-bordered-color;
}
> [class*='ant-col-']::after {
top: 0;
bottom: 0;
left: -1px;
width: 0;
border-left: 1px solid @brand-bordered-color;
}
}
// 边框大小为 `2px`
&-border-width-2 {
border-width: 2px !important;
}
}

View File

@ -0,0 +1,27 @@
.brand {
// 加载容器
&-page-loading {
position: absolute;
top: 0;
right: 0;
left: 0;
min-height: 200px;
}
// `position: absolute` 定位到右上角
&-top-right {
position: absolute;
top: 8px;
right: 8px;
}
// `position: absolute` 定位到左上角
&-top-left {
position: absolute;
top: 8px;
left: 8px;
}
// `nz-range-picker` 日期宽度
&-range-picker__date {
display: inline-block;
width: 240px;
}
}

View File

@ -0,0 +1,13 @@
.brand {
// 将 `nz-ollapse` 的 arrow 图标转化为右边
&-collapse-arrow-reverse {
.@{ant-prefix}-collapse > .@{ant-prefix}-collapse-item > .@{ant-prefix}-collapse-header {
padding-right: 40px;
padding-left: 12px;
.arrow {
right: 16px;
left: unset;
}
}
}
}

View File

@ -0,0 +1,51 @@
@header-search-prefix: ~'@{alain-pro-prefix}__header-search';
@{header-search-prefix} {
display: flex;
.anticon-search {
font-size: 16px;
cursor: pointer;
}
&-input {
width: 0;
overflow: hidden;
background: transparent;
border-radius: 0;
transition: width 0.3s, margin-left 0.3s;
.@{ant-prefix}-select-selection {
background: transparent;
}
input {
padding-right: 0;
padding-left: 0;
background: transparent;
border: 0;
outline: none;
box-shadow: none;
}
&,
&:hover,
&:focus {
border-bottom: 1px solid @border-color-base;
}
}
&-show {
&:hover {
background: none !important;
}
@{header-search-prefix}-input {
width: @alain-pro-header-search-width;
margin-left: 8px;
}
}
}
@{alain-pro-prefix}__dark {
@{alain-pro-prefix}__top-nav {
@{header-search-prefix}-show {
.@{ant-prefix}-input {
color: #fff;
}
}
}
}

View File

@ -0,0 +1,53 @@
@{setting-drawer-prefix} {
&__handle-opened {
right: 300px !important;
}
&__blockChecbox {
display: flex;
&-item {
position: relative;
margin-right: 16px;
// box-shadow: 0 1px 1px 0 rgba(0, 0, 0, 0.1);
border-radius: @border-radius-base;
cursor: pointer;
img {
width: 48px;
}
}
&-selectIcon {
position: absolute;
top: 0;
right: 0;
width: 100%;
height: 100%;
padding-top: 15px;
padding-left: 24px;
color: @primary-color;
font-weight: bold;
font-size: 14px;
}
}
&__handle {
top: 114px;
width: 32px;
height: 32px;
background: rgba(0, 0, 0, 0.38);
&-icon {
font-size: 16px;
}
}
}
.layout-pro-setting-drawer-rtl-mixin(@enabled) when(@enabled=true) {
@{setting-drawer-prefix}-rtl {
@{setting-drawer-prefix} {
&__handle-opened {
right: inherit !important;
left: 300px !important;
}
}
}
}
.layout-pro-setting-drawer-rtl-mixin(@rtl-enabled);

View File

@ -0,0 +1,7 @@
// For pro brand style
@import './_brand.less';
@import './_setting-drawer.less';
@import './_search.less';
@import './_bordered.less';
@import './_collapse.less';

View File

@ -0,0 +1,89 @@
import { Component, DebugElement, ViewChild } from '@angular/core';
import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { createTestContext } from '@delon/testing';
import { AlainThemeModule, Menu, MenuService } from '@delon/theme';
import { LayoutModule } from '../../../layout.module';
import { LayoutProMenuComponent } from '../../components/menu/menu.component';
import { BrandService } from '../../pro.service';
describe('pro: layout-pro-menu', () => {
let fixture: ComponentFixture<TestComponent>;
let dl: DebugElement;
let context: TestComponent;
let srv: BrandService;
let menuSrv: MenuService;
let page: PageObject;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [NoopAnimationsModule, RouterTestingModule.withRoutes([]), AlainThemeModule.forRoot(), LayoutModule],
declarations: [TestComponent]
});
({ fixture, dl, context } = createTestContext(TestComponent));
srv = TestBed.inject(BrandService);
menuSrv = TestBed.inject(MenuService);
page = new PageObject();
});
describe('#menu', () => {
it('should be ingored category menus', () => {
page.appendMenu([{ text: '1', children: [{ text: '1-1' }, { text: '1-2' }] }]).checkCount(2);
});
it('should be ingored menu item when _hidden is true', () => {
page.appendMenu([{ text: '1', children: [{ text: '1-1', hide: true }, { text: '1-2' }] }]).checkCount(1);
});
it('should be navigate router', fakeAsync(() => {
const router = TestBed.inject(Router);
spyOn(router, 'navigateByUrl');
page.appendMenu([{ text: '1', children: [{ text: '1-1', link: '/' }] }]);
page.click();
tick(100);
fixture.detectChanges();
expect(router.navigateByUrl).toHaveBeenCalled();
}));
it('should be auto closed collapse when router changed of mobile', fakeAsync(() => {
spyOnProperty(srv, 'isMobile').and.returnValue(true);
const setCollapsedSpy = spyOn(srv, 'setCollapsed');
page.appendMenu([{ text: '1', children: [{ text: '1-1', link: '/' }] }]).click();
tick(100);
fixture.detectChanges();
expect(setCollapsedSpy).toHaveBeenCalled();
expect(setCollapsedSpy.calls.mostRecent().args[0]).toBe(true);
}));
});
class PageObject {
appendMenu(menus: Menu[]): this {
menuSrv.add(menus);
fixture.detectChanges();
return this;
}
checkCount(count: number = 0): this {
expect(context.comp.menus!.length).toBe(count);
return this;
}
click(pos: number = 0): this {
const el = document.querySelectorAll('.alain-pro__menu-item')[pos] as HTMLElement;
el.querySelector('a')!.click();
fixture.detectChanges();
return this;
}
whenStable(): Promise<void> {
return fixture.whenStable();
}
}
});
@Component({
template: ` <div layout-pro-menu></div> `
})
class TestComponent {
@ViewChild(LayoutProMenuComponent, { static: true }) comp!: LayoutProMenuComponent;
}

View File

@ -0,0 +1,111 @@
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { Component, DebugElement, ViewChild } from '@angular/core';
import { ComponentFixture, TestBed, TestBedStatic } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { RouterTestingModule } from '@angular/router/testing';
import { createTestContext } from '@delon/testing';
import { AlainThemeModule } from '@delon/theme';
import { LayoutModule } from '../../layout.module';
import { LayoutProComponent } from '../pro.component';
import { BrandService } from '../pro.service';
describe('pro: layout-pro', () => {
let injector: TestBedStatic;
let fixture: ComponentFixture<TestComponent>;
let dl: DebugElement;
let context: TestComponent;
let srv: BrandService;
let page: PageObject;
beforeEach(() => {
injector = TestBed.configureTestingModule({
imports: [
HttpClientTestingModule,
NoopAnimationsModule,
RouterTestingModule.withRoutes([]),
AlainThemeModule.forRoot(),
LayoutModule
],
declarations: [TestComponent]
});
});
beforeEach(() => {
({ fixture, dl, context } = createTestContext(TestComponent));
srv = injector.inject(BrandService);
page = new PageObject();
});
describe('should set the body style', () => {
it('when inited', () => {
srv.setCollapsed(true);
srv.setLayout('theme', 'dark');
fixture.detectChanges();
page.checkBodyClass('alain-pro__dark').checkBodyClass('aside-collapsed');
});
it('when destroy', () => {
srv.setCollapsed(true);
srv.setLayout('theme', 'dark');
fixture.detectChanges();
page.checkBodyClass('alain-pro__dark').checkBodyClass('aside-collapsed');
context.comp.ngOnDestroy();
page.checkBodyClass('alain-pro__dark', false);
});
it('when layout changed', () => {
srv.setLayout('contentWidth', 'fixed');
fixture.detectChanges();
page.checkBodyClass('alain-pro__wide');
srv.setLayout('contentWidth', 'fluid');
fixture.detectChanges();
page.checkBodyClass('alain-pro__wide', false);
});
});
describe('#layout', () => {
it('should be hide slider when screen is mobile', () => {
const siderCls = '.alain-pro__sider';
const isMobile = spyOnProperty(srv, 'isMobile', 'get');
// Show sider when not mobile
isMobile.and.returnValue(false);
srv.setCollapsed(true);
fixture.detectChanges();
fixture
.whenStable()
.then(() => {
page.checkCls(siderCls, true);
// Hide sider when is mobile
isMobile.and.returnValue(true);
srv.setCollapsed(true);
fixture.detectChanges();
return fixture.whenStable();
})
.then(() => {
page.checkCls(siderCls, false);
});
});
});
class PageObject {
checkBodyClass(cls: string, status: boolean = true): this {
expect(document.body.classList.contains(cls)).toBe(status);
return this;
}
checkCls(cls: string, status: boolean = true): this {
const nodes = document.querySelectorAll(cls);
if (status) {
expect(nodes.length).toBe(1);
} else {
expect(nodes.length).toBe(0);
}
return this;
}
}
});
@Component({
template: ` <layout-pro #comp></layout-pro> `
})
class TestComponent {
@ViewChild('comp', { static: true }) comp!: LayoutProComponent;
}

View File

@ -0,0 +1,81 @@
import { TestBed, TestBedStatic } from '@angular/core/testing';
import { AlainThemeModule } from '@delon/theme';
import { environment } from '@env/environment';
import { filter } from 'rxjs/operators';
import { BrandService } from '../pro.service';
describe('pro: BrandService', () => {
let injector: TestBedStatic;
let srv: BrandService;
beforeEach(() => {
injector = TestBed.configureTestingModule({
imports: [AlainThemeModule.forRoot()],
providers: [BrandService]
});
});
afterEach(() => ((environment as any).pro = null));
describe('should be initialized configuration', () => {
it('with default', () => {
(environment as any).pro = null;
spyOn(localStorage, 'getItem').and.returnValue(`null`);
srv = injector.get(BrandService);
expect(srv.theme).toBe('dark');
expect(srv.menu).toBe('side');
expect(srv.contentWidth).toBe('fluid');
});
it('with environment', () => {
(environment as any).pro = { theme: 'light' };
spyOn(localStorage, 'getItem').and.returnValue(`null`);
srv = injector.get(BrandService);
expect(srv.theme).toBe('light');
});
it('with settings', () => {
(environment as any).pro = null;
spyOn(localStorage, 'getItem').and.returnValue(JSON.stringify({ theme: 'light', menu: 'top' }));
srv = injector.get(BrandService);
expect(srv.theme).toBe('light');
expect(srv.menu).toBe('top');
});
});
describe('should be trigger notify', () => {
beforeEach(() => (srv = injector.get(BrandService)));
it('when mobile changed in constructor', done => {
srv.notify.pipe(filter(v => v != null && v === 'mobile')).subscribe(type => {
expect(true).toBe(true);
done();
});
});
it('when layout changed', done => {
srv.notify.pipe(filter(v => v != null && v === 'layout')).subscribe(type => {
expect(true).toBe(true);
done();
});
srv.setLayout('a', 1);
});
});
it('#setCollapsed', () => {
srv = injector.get(BrandService);
srv.setCollapsed(false);
expect(srv.collapsed).toBe(false);
srv.setCollapsed();
expect(srv.collapsed).toBe(true);
srv.setCollapsed();
expect(srv.collapsed).toBe(false);
});
it('should be onlyIcon always be false when menu is side', () => {
srv = injector.get(BrandService);
srv.setLayout('menu', 'top');
srv.setLayout('onlyIcon', true);
expect(srv.onlyIcon).toBe(true);
srv.setLayout('menu', 'side');
expect(srv.onlyIcon).toBe(false);
});
});