项目初始化

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

46
src/app/app.component.ts Normal file
View File

@ -0,0 +1,46 @@
import { Component, ElementRef, OnInit, Renderer2 } from '@angular/core';
import { NavigationEnd, NavigationError, RouteConfigLoadStart, Router } from '@angular/router';
import { TitleService, VERSION as VERSION_ALAIN } from '@delon/theme';
import { environment } from '@env/environment';
import { NzModalService } from 'ng-zorro-antd/modal';
import { VERSION as VERSION_ZORRO } from 'ng-zorro-antd/version';
@Component({
selector: 'app-root',
template: ` <router-outlet></router-outlet> `
})
export class AppComponent implements OnInit {
constructor(
el: ElementRef,
renderer: Renderer2,
private router: Router,
private titleSrv: TitleService,
private modalSrv: NzModalService
) {
renderer.setAttribute(el.nativeElement, 'ng-alain-version', VERSION_ALAIN.full);
renderer.setAttribute(el.nativeElement, 'ng-zorro-version', VERSION_ZORRO.full);
}
ngOnInit(): void {
let configLoad = false;
this.router.events.subscribe(ev => {
if (ev instanceof RouteConfigLoadStart) {
configLoad = true;
}
if (configLoad && ev instanceof NavigationError) {
this.modalSrv.confirm({
nzTitle: `提醒`,
nzContent: environment.production ? `应用可能已发布新版本,请点击刷新才能生效。` : `无法加载路由:${ev.url}`,
nzCancelDisabled: false,
nzOkText: '刷新',
nzCancelText: '忽略',
nzOnOk: () => location.reload()
});
}
if (ev instanceof NavigationEnd) {
this.titleSrv.setTitle();
this.modalSrv.closeAll();
}
});
}
}

72
src/app/app.module.ts Normal file
View File

@ -0,0 +1,72 @@
/* eslint-disable import/order */
/* eslint-disable import/no-duplicates */
import { HttpClientModule } from '@angular/common/http';
import { default as ngLang } from '@angular/common/locales/zh';
import { APP_INITIALIZER, LOCALE_ID, NgModule, Type } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { SimpleInterceptor } from '@delon/auth';
import { NzNotificationModule } from 'ng-zorro-antd/notification';
// #region global third module
import { BidiModule } from '@angular/cdk/bidi';
const GLOBAL_THIRD_MODULES: Array<Type<any>> = [BidiModule];
// #endregion
// #region Http Interceptors
import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { DefaultInterceptor } from '@core';
const INTERCEPTOR_PROVIDES = [
{ provide: HTTP_INTERCEPTORS, useClass: SimpleInterceptor, multi: true },
{ provide: HTTP_INTERCEPTORS, useClass: DefaultInterceptor, multi: true }
];
// #endregion
// #region Startup Service
import { StartupService } from '@core';
export function StartupServiceFactory(startupService: StartupService): () => Promise<void> {
return () => startupService.load();
}
const APPINIT_PROVIDES = [
StartupService,
{
provide: APP_INITIALIZER,
useFactory: StartupServiceFactory,
deps: [StartupService],
multi: true
}
];
// #endregion
import { AppComponent } from './app.component';
import { CoreModule } from './core/core.module';
import { GlobalConfigModule } from './global-config.module';
import { LayoutModule } from './layout/layout.module';
import { RoutesModule } from './routes/routes.module';
import { SharedModule } from './shared/shared.module';
import { STWidgetModule } from './shared/widget/st-widget.module';
import { Observable } from 'rxjs';
@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule,
BrowserAnimationsModule,
HttpClientModule,
GlobalConfigModule.forRoot(),
CoreModule,
SharedModule,
LayoutModule,
RoutesModule,
STWidgetModule,
NzNotificationModule,
...GLOBAL_THIRD_MODULES
],
providers: [...INTERCEPTOR_PROVIDES, ...APPINIT_PROVIDES],
bootstrap: [AppComponent]
})
export class AppModule {}

5
src/app/core/README.md Normal file
View File

@ -0,0 +1,5 @@
### CoreModule
**应** 仅只留 `providers` 属性。
**作用:** 一些通用服务例如用户消息、HTTP数据访问。

View File

@ -0,0 +1,12 @@
import { NgModule, Optional, SkipSelf } from '@angular/core';
import { throwIfAlreadyLoaded } from './module-import-guard';
@NgModule({
providers: []
})
export class CoreModule {
constructor(@Optional() @SkipSelf() parentModule: CoreModule) {
throwIfAlreadyLoaded(parentModule, 'CoreModule');
}
}

View File

@ -0,0 +1,90 @@
/*
* @Author: Maple
* @Date: 2021-03-22 11:42:26
* @LastEditors: Do not edit
* @LastEditTime: 2021-05-27 14:06:18
* @Description: 全局核心服务
*/
import { Injectable, Injector } from '@angular/core';
import { Router } from '@angular/router';
import { ReuseTabService } from '@delon/abc/reuse-tab';
import { DA_SERVICE_TOKEN, ITokenService } from '@delon/auth';
import { CacheService } from '@delon/cache';
import { SettingsService } from '@delon/theme';
import { EnvironmentService } from '@env/environment.service';
import { NzMessageService } from 'ng-zorro-antd/message';
@Injectable({
providedIn: 'root',
})
export class CoreService {
// 获取当前登录用户信息
public $api_get_current_user_info = `/scm/cuc/cuc/user/getUserDetail`;
// 获取当前用户所拥有的菜单
public $api_get_current_user_menus = `/scm/cuc/cuc/functionInfo/getUserHaveFunctionsList`;
constructor(private injector: Injector) {}
// 注入路由
public get router(): Router {
return this.injector.get(Router);
}
// 注入全局设置服务
public get settingSrv(): SettingsService {
return this.injector.get(SettingsService);
}
// 注入缓存服务
public get cacheSrv(): CacheService {
return this.injector.get(CacheService);
}
// 注入令牌服务
public get tokenSrv(): ITokenService {
return this.injector.get(DA_SERVICE_TOKEN);
}
// 注入消息服务
public get msgSrv(): NzMessageService {
return this.injector.get(NzMessageService);
}
// 注入环境服务
public get envSrv(): EnvironmentService {
return this.injector.get(EnvironmentService);
}
// 注入路由复用服务
private get reuseTabService(): ReuseTabService {
return this.injector.get(ReuseTabService);
}
// 登录状态
public get loginStatus(): boolean {
try {
return !!this.tokenSrv.get()?.token;
} catch (error) {
return false;
}
}
// 权限认证凭据TOKEN
public get token(): string {
return this.tokenSrv.get()?.token || '';
}
/**
* 登出系统
* @param showMsg 是否显示登录过期弹窗
*/
logout(showMsg: boolean = false): void {
if (showMsg) {
this.msgSrv.warning('未登录或登录信息已过期,请重新登录!');
}
this.settingSrv.setUser({});
this.tokenSrv.clear();
this.cacheSrv.clear();
this.router.navigate([this.tokenSrv.login_url]);
}
}

6
src/app/core/index.ts Normal file
View File

@ -0,0 +1,6 @@
export * from './module-import-guard';
export * from './net/default.interceptor';
// Services
export * from './core.service';
export * from './startup/startup.service';

View File

@ -0,0 +1,6 @@
// https://angular.io/guide/styleguide#style-04-12
export function throwIfAlreadyLoaded(parentModule: any, moduleName: string): void {
if (parentModule) {
throw new Error(`${moduleName} has already been loaded. Import Core modules in the AppModule only.`);
}
}

View File

@ -0,0 +1,261 @@
import {
HttpErrorResponse,
HttpEvent,
HttpHandler,
HttpHeaders,
HttpInterceptor,
HttpRequest,
HttpResponseBase
} from '@angular/common/http';
import { Injectable, Injector } from '@angular/core';
import { Router } from '@angular/router';
import { DA_SERVICE_TOKEN, ITokenService } from '@delon/auth';
import { _HttpClient } from '@delon/theme';
import { environment } from '@env/environment';
import { NzNotificationService } from 'ng-zorro-antd/notification';
import { BehaviorSubject, Observable, of, throwError } from 'rxjs';
import { catchError, filter, mergeMap, switchMap, take } from 'rxjs/operators';
const CODEMESSAGE: { [key: number]: string } = {
200: '服务器成功返回请求的数据。',
201: '新建或修改数据成功。',
202: '一个请求已经进入后台排队(异步任务)。',
204: '删除数据成功。',
400: '发出的请求有错误,服务器没有进行新建或修改数据的操作。',
401: '用户没有权限(令牌、用户名、密码错误)。',
403: '用户得到授权,但是访问是被禁止的。',
404: '发出的请求针对的是不存在的记录,服务器没有进行操作。',
406: '请求的格式不可得。',
410: '请求的资源被永久删除,且不会再得到的。',
422: '当创建一个对象时,发生一个验证错误。',
500: '服务器发生错误,请检查服务器。',
502: '网关错误。',
503: '服务不可用,服务器暂时过载或维护。',
504: '网关超时。'
};
/**
* 默认HTTP拦截器其注册细节见 `app.module.ts`
*/
@Injectable()
export class DefaultInterceptor implements HttpInterceptor {
private refreshTokenEnabled = environment.api.refreshTokenEnabled;
private refreshTokenType: 're-request' | 'auth-refresh' = environment.api.refreshTokenType;
private refreshToking = false;
private refreshToken$: BehaviorSubject<any> = new BehaviorSubject<any>(null);
constructor(private injector: Injector) {
if (this.refreshTokenType === 'auth-refresh') {
this.buildAuthRefresh();
}
}
private get notification(): NzNotificationService {
return this.injector.get(NzNotificationService);
}
private get tokenSrv(): ITokenService {
return this.injector.get(DA_SERVICE_TOKEN);
}
private get http(): _HttpClient {
return this.injector.get(_HttpClient);
}
private goTo(url: string): void {
setTimeout(() => this.injector.get(Router).navigateByUrl(url));
}
private checkStatus(ev: HttpResponseBase): void {
if ((ev.status >= 200 && ev.status < 300) || ev.status === 401) {
return;
}
const errortext = CODEMESSAGE[ev.status] || ev.statusText;
this.notification.error(`请求错误 ${ev.status}: ${ev.url}`, errortext);
}
/**
* 刷新 Token 请求
*/
private refreshTokenRequest(): Observable<any> {
const model = this.tokenSrv.get();
return this.http.post(`/api/auth/refresh`, null, null, { headers: { refresh_token: model?.refresh_token || '' } });
}
// #region 刷新Token方式一使用 401 重新刷新 Token
private tryRefreshToken(ev: HttpResponseBase, req: HttpRequest<any>, next: HttpHandler): Observable<any> {
// 1、若请求为刷新Token请求表示来自刷新Token可以直接跳转登录页
if ([`/api/auth/refresh`].some(url => req.url.includes(url))) {
this.toLogin();
return throwError(ev);
}
// 2、如果 `refreshToking` 为 `true` 表示已经在请求刷新 Token 中,后续所有请求转入等待状态,直至结果返回后再重新发起请求
if (this.refreshToking) {
return this.refreshToken$.pipe(
filter(v => !!v),
take(1),
switchMap(() => next.handle(this.reAttachToken(req)))
);
}
// 3、尝试调用刷新 Token
this.refreshToking = true;
this.refreshToken$.next(null);
return this.refreshTokenRequest().pipe(
switchMap(res => {
// 通知后续请求继续执行
this.refreshToking = false;
this.refreshToken$.next(res);
// 重新保存新 token
this.tokenSrv.set(res);
// 重新发起请求
return next.handle(this.reAttachToken(req));
}),
catchError(err => {
this.refreshToking = false;
this.toLogin();
return throwError(err);
})
);
}
/**
* 重新附加新 Token 信息
*
* > 由于已经发起的请求,不会再走一遍 `@delon/auth` 因此需要结合业务情况重新附加新的 Token
*/
private reAttachToken(req: HttpRequest<any>): HttpRequest<any> {
// 以下示例是以 NG-ALAIN 默认使用 `SimpleInterceptor`
const token = this.tokenSrv.get()?.token;
return req.clone({
setHeaders: {
token: `Bearer ${token}`
}
});
}
// #endregion
// #region 刷新Token方式二使用 `@delon/auth` 的 `refresh` 接口
private buildAuthRefresh(): void {
if (!this.refreshTokenEnabled) {
return;
}
this.tokenSrv.refresh
.pipe(
filter(() => !this.refreshToking),
switchMap(res => {
console.log(res);
this.refreshToking = true;
return this.refreshTokenRequest();
})
)
.subscribe(
res => {
// TODO: Mock expired value
res.expired = +new Date() + 1000 * 60 * 5;
this.refreshToking = false;
this.tokenSrv.set(res);
},
() => this.toLogin()
);
}
// #endregion
private toLogin(): void {
this.notification.error(`未登录或登录已过期,请重新登录。`, ``);
this.goTo(this.tokenSrv.login_url!);
}
private handleData(ev: HttpResponseBase, req: HttpRequest<any>, next: HttpHandler): Observable<any> {
this.checkStatus(ev);
// 业务处理:一些通用操作
switch (ev.status) {
case 200:
// 业务层级错误处理以下是假定restful有一套统一输出格式指不管成功与否都有相应的数据格式情况下进行处理
// 例如响应内容:
// 错误内容:{ status: 1, msg: '非法参数' }
// 正确内容:{ status: 0, response: { } }
// 则以下代码片断可直接适用
// if (ev instanceof HttpResponse) {
// const body = ev.body;
// if (body && body.status !== 0) {
// this.injector.get(NzMessageService).error(body.msg);
// // 注意这里如果继续抛出错误会被行254的 catchError 二次拦截,导致外部实现的 Pipe、subscribe 操作被中断例如this.http.get('/').subscribe() 不会触发
// // 如果你希望外部实现需要手动移除行254
// return throwError({});
// } else {
// // 忽略 Blob 文件体
// if (ev.body instanceof Blob) {
// return of(ev);
// }
// // 重新修改 `body` 内容为 `response` 内容,对于绝大多数场景已经无须再关心业务状态码
// return of(new HttpResponse(Object.assign(ev, { body: body.response })));
// // 或者依然保持完整的格式
// return of(ev);
// }
// }
break;
case 401:
if (this.refreshTokenEnabled && this.refreshTokenType === 're-request') {
return this.tryRefreshToken(ev, req, next);
}
this.toLogin();
break;
case 403:
case 404:
case 500:
this.goTo(`/exception/${ev.status}`);
break;
default:
if (ev instanceof HttpErrorResponse) {
console.warn(
'未可知错误大部分是由于后端不支持跨域CORS或无效配置引起请参考 https://ng-alain.com/docs/server 解决跨域问题',
ev
);
}
break;
}
if (ev instanceof HttpErrorResponse) {
return throwError(ev);
} else {
return of(ev);
}
}
private getAdditionalHeaders(headers?: HttpHeaders): { [name: string]: string } {
const res: { [name: string]: string } = {};
// const lang = this.injector.get(ALAIN_I18N_TOKEN).currentLang;
// if (!headers?.has('Accept-Language') && lang) {
// res['Accept-Language'] = lang;
// }
return res;
}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
// 统一加上服务端前缀
let url = req.url;
if (!url.startsWith('https://') && !url.startsWith('http://')) {
const { baseUrl } = environment.api;
url = baseUrl + (baseUrl.endsWith('/') && url.startsWith('/') ? url.substring(1) : url);
}
const newReq = req.clone({ url, setHeaders: this.getAdditionalHeaders(req.headers) });
return next.handle(newReq).pipe(
mergeMap(ev => {
// 允许统一对请求错误处理
if (ev instanceof HttpResponseBase) {
return this.handleData(ev, newReq, next);
}
// 若一切都正常,则后续操作
return of(ev);
}),
catchError((err: HttpErrorResponse) => this.handleData(err, newReq, next))
);
}
}

View File

@ -0,0 +1,158 @@
import { HttpClient } from '@angular/common/http';
import { Inject, Injectable } from '@angular/core';
import { ACLService } from '@delon/acl';
import { MenuService, SettingsService, TitleService, _HttpClient } from '@delon/theme';
import { NzSafeAny } from 'ng-zorro-antd/core/types';
import { NzIconService } from 'ng-zorro-antd/icon';
import { Observable, zip } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { ICONS } from '../../../style-icons';
import { ICONS_AUTO } from '../../../style-icons-auto';
import { CoreService } from '../core.service';
/**
* Used for application startup
* Generally used to get the basic data of the application, like: Menu Data, User Data, etc.
*/
@Injectable()
export class StartupService {
constructor(
iconSrv: NzIconService,
private menuService: MenuService,
private settingService: SettingsService,
private aclService: ACLService,
private titleService: TitleService,
private httpClient: _HttpClient,
private coreSrv: CoreService
) {
iconSrv.addIcon(...ICONS_AUTO, ...ICONS);
}
// TODO: 退出登录时需要清理用户信息
load(): Promise<void> {
return new Promise(resolve => {
let data;
if (this.coreSrv.loginStatus) {
// 本地菜单
// data = this.loadMockData();
// 远程菜单
data = this.loadRemoteData();
} else {
data = this.loadMockData();
}
data
.pipe(
catchError(res => {
console.warn(`StartupService.load: Network request failed`, res);
resolve();
return [];
})
)
.subscribe(
([appData, userData, menuData]) => this.initSystem(appData, userData, menuData),
err => {
console.log(err);
},
() => resolve()
);
});
}
/**
* 系统初始化
*
* @param langData 翻译数据
* @param appData App应用数据
* @param userData 用户数据
* @param menuData 菜单数据
*/
private initSystem(appData: NzSafeAny, userData: NzSafeAny, menuData: NzSafeAny): void {
// 应用信息:包括站点名、描述、年份
this.settingService.setApp(appData);
// 用户信息:包括姓名、头像、邮箱地址
this.settingService.setUser(userData);
// ACL设置权限为全量
this.aclService.setFull(true);
// 初始化菜单
this.menuService.add(menuData);
// 设置页面标题的后缀
this.titleService.default = '';
this.titleService.suffix = appData.name;
}
/**
* @description 加载本地模拟数据
* @returns 程序初始化数据
*/
loadMockData(): Observable<[object, object, object]> {
// 登录时调用远程数据, 非登录状态下调用Mock数据
// App数据
const appData = this.httpClient.get(`assets/mocks/app-data.json`).pipe(map((res: any) => res.app));
// 用户数据
const userData = this.coreSrv.loginStatus
? this.httpClient.post(this.coreSrv.$api_get_current_user_info, {}).pipe(map((res: any) => res.data))
: this.httpClient.get('assets/mocks/user-data.json').pipe(map((res: any) => res.user));
// 菜单数据
const menuData = this.httpClient.get('assets/mocks/menu-data.json').pipe(map((res: any) => res.menu));
return zip(appData, userData, menuData);
}
/**
* @description 加载远程数据
* @returns 程序初始化数据
*/
loadRemoteData(): Observable<[object, object, object]> {
// 登录时调用远程数据, 非登录状态下调用Mock数据
// App数据
const appData = this.httpClient.get(`assets/mocks/app-data.json`).pipe(map((res: any) => res.app));
// 用户数据
const userData = this.coreSrv.loginStatus
? this.httpClient.post(this.coreSrv.$api_get_current_user_info, {}).pipe(map((res: any) => res.data))
: this.httpClient.get('assets/mocks/user-data.json').pipe(map((res: any) => res.user));
// 菜单数据
const menuData = this.httpClient
.post(this.coreSrv.$api_get_current_user_menus, {
appId: this.coreSrv.envSrv.getEnvironment().appId
})
.pipe(map((res: any) => res.data));
return zip(appData, userData, menuData);
}
// load(): Observable<void> {
// const defaultLang = this.i18n.defaultLang;
// return zip(this.i18n.loadLangData(defaultLang), this.httpClient.get('assets/tmp/app-data.json')).pipe(
// // 接收其他拦截器后产生的异常消息
// catchError(res => {
// console.warn(`StartupService.load: Network request failed`, res);
// return [];
// }),
// map(([langData, appData]: [Record<string, string>, NzSafeAny]) => {
// // setting language data
// this.i18n.use(defaultLang, langData);
// // 应用信息:包括站点名、描述、年份
// this.settingService.setApp(appData.app);
// // 用户信息:包括姓名、头像、邮箱地址
// this.settingService.setUser(appData.user);
// // ACL设置权限为全量
// this.aclService.setFull(true);
// // 初始化菜单
// this.menuService.add(appData.menu);
// // 设置页面标题的后缀
// this.titleService.default = '';
// this.titleService.suffix = appData.app.name;
// })
// );
// }
}

View File

@ -0,0 +1,65 @@
/* eslint-disable import/order */
import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core';
import { throwIfAlreadyLoaded } from '@core';
import { ReuseTabMatchMode, ReuseTabService, ReuseTabStrategy } from '@delon/abc/reuse-tab';
import { DelonACLModule } from '@delon/acl';
import { AlainThemeModule } from '@delon/theme';
import { AlainConfig, ALAIN_CONFIG } from '@delon/util';
import { environment } from '@env/environment';
// Please refer to: https://ng-alain.com/docs/global-config
// #region NG-ALAIN Config
const alainConfig: AlainConfig = {
st: { modal: { size: 'lg' } },
pageHeader: { homeI18n: 'home', recursiveBreadcrumb: true },
auth: { login_url: '/passport/login' }
};
const alainModules = [AlainThemeModule.forRoot(), DelonACLModule.forRoot()];
const alainProvides = [{ provide: ALAIN_CONFIG, useValue: alainConfig }];
// #region reuse-tab
import { RouteReuseStrategy } from '@angular/router';
alainProvides.push({
provide: RouteReuseStrategy,
useClass: ReuseTabStrategy,
deps: [ReuseTabService]
} as any);
// #endregion
// #endregion
// Please refer to: https://ng.ant.design/docs/global-config/en#how-to-use
// #region NG-ZORRO Config
import { NzConfig, NZ_CONFIG } from 'ng-zorro-antd/core/config';
const ngZorroConfig: NzConfig = {};
const zorroProvides = [{ provide: NZ_CONFIG, useValue: ngZorroConfig }];
// #endregion
@NgModule({
imports: [...alainModules, ...(environment.modules || [])]
})
export class GlobalConfigModule {
constructor(@Optional() @SkipSelf() parentModule: GlobalConfigModule, reuseTabService: ReuseTabService) {
throwIfAlreadyLoaded(parentModule, 'GlobalConfigModule');
// NOTICE: Only valid for menus with reuse property
// Pls refer to the E-Mail demo effect
reuseTabService.mode = ReuseTabMatchMode.MenuForce;
// Shouled be trigger init, you can ingore when used `reuse-tab` component in layout component
reuseTabService.init();
}
static forRoot(): ModuleWithProviders<GlobalConfigModule> {
return {
ngModule: GlobalConfigModule,
providers: [...alainProvides, ...zorroProvides]
};
}
}

View File

@ -0,0 +1,64 @@
import { LayoutModule as CDKLayoutModule } from '@angular/cdk/layout';
import { CommonModule } from '@angular/common';
import { NgModule, Type } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { GlobalFooterModule } from '@delon/abc/global-footer';
import { NoticeIconModule } from '@delon/abc/notice-icon';
import { AlainThemeModule } from '@delon/theme';
import { ThemeBtnModule } from '@delon/theme/theme-btn';
import { ScrollbarModule } from '@shared';
import { NzAlertModule } from 'ng-zorro-antd/alert';
import { NzAutocompleteModule } from 'ng-zorro-antd/auto-complete';
import { NzAvatarModule } from 'ng-zorro-antd/avatar';
import { NzBadgeModule } from 'ng-zorro-antd/badge';
import { NzButtonModule } from 'ng-zorro-antd/button';
import { NzDividerModule } from 'ng-zorro-antd/divider';
import { NzDrawerModule } from 'ng-zorro-antd/drawer';
import { NzDropDownModule } from 'ng-zorro-antd/dropdown';
import { NzIconModule } from 'ng-zorro-antd/icon';
import { NzLayoutModule } from 'ng-zorro-antd/layout';
import { NzMessageModule } from 'ng-zorro-antd/message';
import { NzSelectModule } from 'ng-zorro-antd/select';
import { NzSpinModule } from 'ng-zorro-antd/spin';
import { NzSwitchModule } from 'ng-zorro-antd/switch';
import { NzTimelineModule } from 'ng-zorro-antd/timeline';
import { NzToolTipModule } from 'ng-zorro-antd/tooltip';
import { LayoutPassportComponent } from './passport/passport.component';
import { PRO_COMPONENTS } from './pro/index';
const COMPONENTS: Array<Type<any>> = [...PRO_COMPONENTS, LayoutPassportComponent];
@NgModule({
imports: [
CommonModule,
RouterModule,
FormsModule,
AlainThemeModule,
GlobalFooterModule,
CDKLayoutModule,
NzSpinModule,
NzDropDownModule,
NzIconModule,
NzDrawerModule,
NzAutocompleteModule,
NzAvatarModule,
NzSwitchModule,
NzToolTipModule,
NzSelectModule,
NzDividerModule,
NzAlertModule,
NzLayoutModule,
NzButtonModule,
NzBadgeModule,
NzTimelineModule,
NoticeIconModule,
ThemeBtnModule,
ScrollbarModule,
NzMessageModule
],
declarations: COMPONENTS,
exports: COMPONENTS
})
export class LayoutModule {}

View File

@ -0,0 +1,13 @@
<div class="container">
<!-- <pro-langs class="langs" btnClass></pro-langs> -->
<div class="wrap">
<div class="top">
<div class="head">
<img class="logo" src="./assets/logo-color.svg">
<span class="title">ng-alain pro</span>
</div>
<div class="desc">武林中最有影响力的《葵花宝典》;欲练神功,挥刀自宫</div>
</div>
<router-outlet></router-outlet>
</div>
</div>

View File

@ -0,0 +1,74 @@
@import '~@delon/theme/index';
:host {
::ng-deep {
.container {
display: flex;
flex-direction: column;
min-height: 100%;
background: #f0f2f5;
}
.langs {
width: 100%;
height: 40px;
line-height: 44px;
text-align: right;
.ant-dropdown-trigger {
display: inline-block;
}
.anticon {
margin-top: 24px;
margin-right: 24px;
font-size: 14px;
vertical-align: top;
cursor: pointer;
}
}
.wrap {
flex: 1;
padding: 32px 0;
}
.ant-form-item {
margin-bottom: 24px;
}
@media (min-width: @screen-md-min) {
.container {
background-image: url('https://gw.alipayobjects.com/zos/rmsportal/TVYTbAXWheQpRcWDaDMu.svg');
background-repeat: no-repeat;
background-position: center 110px;
background-size: 100%;
}
.wrap {
padding: 32px 0 24px;
}
}
.top {
text-align: center;
}
.header {
height: 44px;
line-height: 44px;
a {
text-decoration: none;
}
}
.logo {
height: 44px;
margin-right: 16px;
}
.title {
position: relative;
color: @heading-color;
font-weight: 600;
font-size: 33px;
font-family: 'Myriad Pro', 'Helvetica Neue', Arial, Helvetica, sans-serif;
vertical-align: middle;
}
.desc {
margin-top: 12px;
margin-bottom: 40px;
color: @text-color-secondary;
font-size: @font-size-base;
}
}
}

View File

@ -0,0 +1,30 @@
import { Component, Inject, OnInit } from '@angular/core';
import { DA_SERVICE_TOKEN, ITokenService } from '@delon/auth';
@Component({
selector: 'layout-passport',
templateUrl: './passport.component.html',
styleUrls: ['./passport.component.less']
})
export class LayoutPassportComponent implements OnInit {
links = [
{
title: '帮助',
href: ''
},
{
title: '隐私',
href: ''
},
{
title: '条款',
href: ''
}
];
constructor(@Inject(DA_SERVICE_TOKEN) private tokenService: ITokenService) {}
ngOnInit(): void {
this.tokenService.clear();
}
}

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);
});
});

View File

@ -0,0 +1,109 @@
<page-header [title]="'查询表格'"></page-header>
<nz-card [nzBordered]="false">
<form nz-form [nzLayout]="'inline'" (ngSubmit)="getData()" class="search__form">
<div nz-row [nzGutter]="{ xs: 8, sm: 8, md: 8, lg: 24, xl: 48, xxl: 48 }">
<div nz-col nzMd="8" nzSm="24">
<nz-form-item>
<nz-form-label nzFor="no">规则编号</nz-form-label>
<nz-form-control>
<input nz-input [(ngModel)]="q.no" name="no" placeholder="请输入" id="no" />
</nz-form-control>
</nz-form-item>
</div>
<div nz-col nzMd="8" nzSm="24">
<nz-form-item>
<nz-form-label nzFor="status">使用状态</nz-form-label>
<nz-form-control>
<nz-select [(ngModel)]="q.status" name="status" id="status" [nzPlaceHolder]="'请选择'" [nzShowSearch]="true">
<nz-option *ngFor="let i of status; let idx = index" [nzLabel]="i.text" [nzValue]="idx"></nz-option>
</nz-select>
</nz-form-control>
</nz-form-item>
</div>
<div nz-col nzMd="8" nzSm="24" *ngIf="expandForm">
<nz-form-item>
<nz-form-label nzFor="callNo">调用次数</nz-form-label>
<nz-form-control>
<input nz-input id="callNo" />
</nz-form-control>
</nz-form-item>
</div>
<div nz-col nzMd="8" nzSm="24" *ngIf="expandForm">
<nz-form-item>
<nz-form-label nzFor="updatedAt">更新日期</nz-form-label>
<nz-form-control>
<nz-date-picker id="updatedAt"></nz-date-picker>
</nz-form-control>
</nz-form-item>
</div>
<div nz-col nzMd="8" nzSm="24" *ngIf="expandForm">
<nz-form-item>
<nz-form-label nzFor="status2">使用状态</nz-form-label>
<nz-form-control>
<nz-select [nzPlaceHolder]="'请选择'" nzId="status2" [nzShowSearch]="true">
<nz-option *ngFor="let i of status; let idx = index" [nzLabel]="i.text" [nzValue]="idx"></nz-option>
</nz-select>
</nz-form-control>
</nz-form-item>
</div>
<div nz-col nzMd="8" nzSm="24" *ngIf="expandForm">
<nz-form-item>
<nz-form-label nzFor="status3">使用状态</nz-form-label>
<nz-form-control>
<nz-select [nzPlaceHolder]="'请选择'" nzId="status3" [nzShowSearch]="true">
<nz-option *ngFor="let i of status; let idx = index" [nzLabel]="i.text" [nzValue]="idx"></nz-option>
</nz-select>
</nz-form-control>
</nz-form-item>
</div>
<div nz-col [nzSpan]="expandForm ? 24 : 8" [class.text-right]="expandForm">
<button nz-button type="submit" [nzType]="'primary'" [nzLoading]="loading">查询</button>
<button nz-button type="reset" (click)="reset()" class="mx-sm">重置</button>
<a (click)="expandForm = !expandForm">
{{ expandForm ? '收起' : '展开' }}
<i nz-icon [nzType]="expandForm ? 'up' : 'down'"></i>
</a>
</div>
</div>
</form>
<button nz-button (click)="add(modalContent)" [nzType]="'primary'">
<i nz-icon nzType="plus"></i>
<span>新建</span>
</button>
<ng-container *ngIf="selectedRows.length > 0">
<button nz-button>批量操作</button>
<button nz-button nz-dropdown [nzDropdownMenu]="batchMenu" nzPlacement="bottomLeft">
更多操作
<i nz-icon nzType="down"></i>
</button>
<nz-dropdown-menu #batchMenu="nzDropdownMenu">
<ul nz-menu>
<li nz-menu-item (click)="remove()">删除</li>
<li nz-menu-item (click)="approval()">批量审批</li>
</ul>
</nz-dropdown-menu>
</ng-container>
<div class="my-md">
<nz-alert [nzType]="'info'" [nzShowIcon]="true" [nzMessage]="message">
<ng-template #message>
已选择
<strong class="text-primary">{{ selectedRows.length }}</strong>&nbsp;&nbsp; 服务调用总计 <strong>{{ totalCallNo
}}</strong>
<a *ngIf="totalCallNo > 0" (click)="st.clearCheck()" class="ml-lg">清空</a>
</ng-template>
</nz-alert>
</div>
<st #st [columns]="columns" [data]="data" [loading]="loading" (change)="stChange($event)">
<ng-template st-row="status" let-i>
<nz-badge [nzStatus]="i.statusType" [nzText]="i.statusText"></nz-badge>
</ng-template>
</st>
</nz-card>
<ng-template #modalContent>
<nz-form-item>
<nz-form-label nzFor="no">描述</nz-form-label>
<nz-form-control>
<input nz-input [(ngModel)]="description" name="description" placeholder="请输入" id="no" />
</nz-form-control>
</nz-form-item>
</ng-template>

View File

@ -0,0 +1,6 @@
@import '~@delon/theme/index';
:host {
::ng-deep {
}
}

View File

@ -0,0 +1,166 @@
import { Component, ChangeDetectionStrategy, ChangeDetectorRef, TemplateRef, ViewChild } from '@angular/core';
import { STComponent, STColumn, STData, STChange } from '@delon/abc/st';
import { _HttpClient } from '@delon/theme';
import { NzSafeAny } from 'ng-zorro-antd/core/types';
import { NzMessageService } from 'ng-zorro-antd/message';
import { NzModalService } from 'ng-zorro-antd/modal';
import { map, tap } from 'rxjs/operators';
@Component({
selector: 'app-dashboard',
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.less'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DashboardComponent {
q: {
pi: number;
ps: number;
no: string;
sorter: string;
status: number | null;
statusList: NzSafeAny[];
} = {
pi: 1,
ps: 10,
no: '',
sorter: '',
status: null,
statusList: []
};
data: any[] = [];
loading = false;
status = [
{ index: 0, text: '关闭', value: false, type: 'default', checked: false },
{
index: 1,
text: '运行中',
value: false,
type: 'processing',
checked: false
},
{ index: 2, text: '已上线', value: false, type: 'success', checked: false },
{ index: 3, text: '异常', value: false, type: 'error', checked: false }
];
@ViewChild('st', { static: true })
st!: STComponent;
columns: STColumn[] = [
{ title: '', index: 'key', type: 'checkbox' },
{ title: '规则编号', index: 'no' },
{ title: '描述', index: 'description' },
{
title: '服务调用次数',
index: 'callNo',
type: 'number',
format: item => `${item.callNo}`,
sort: {
compare: (a, b) => a.callNo - b.callNo
}
},
{
title: '状态',
index: 'status',
render: 'status',
filter: {
menus: this.status,
fn: (filter, record) => record.status === filter.index
}
},
{
title: '更新时间',
index: 'updatedAt',
type: 'date',
sort: {
compare: (a, b) => a.updatedAt - b.updatedAt
}
},
{
title: '操作',
buttons: [
{
text: '配置',
click: item => this.msg.success(`配置${item.no}`)
},
{
text: '订阅警报',
click: item => this.msg.success(`订阅警报${item.no}`)
}
]
}
];
selectedRows: STData[] = [];
description = '';
totalCallNo = 0;
expandForm = false;
constructor(private http: _HttpClient, public msg: NzMessageService, private modalSrv: NzModalService, private cdr: ChangeDetectorRef) {}
ngOnInit(): void {
this.getData();
}
getData(): void {
this.loading = true;
this.q.statusList = this.status.filter(w => w.checked).map(item => item.index);
if (this.q.status !== null && this.q.status > -1) {
this.q.statusList.push(this.q.status);
}
this.http
.get('/rule?_allow_anonymous=true', this.q)
.pipe(
map((list: Array<{ status: number; statusText: string; statusType: string }>) =>
list.map(i => {
const statusItem = this.status[i.status];
i.statusText = statusItem.text;
i.statusType = statusItem.type;
return i;
})
),
tap(() => (this.loading = false))
)
.subscribe(res => {
this.data = res;
this.cdr.detectChanges();
});
}
stChange(e: STChange): void {
switch (e.type) {
case 'checkbox':
this.selectedRows = e.checkbox!;
this.totalCallNo = this.selectedRows.reduce((total, cv) => total + cv.callNo, 0);
this.cdr.detectChanges();
break;
case 'filter':
this.getData();
break;
}
}
remove(): void {
this.http.delete('/rule', { nos: this.selectedRows.map(i => i.no).join(',') }).subscribe(() => {
this.getData();
this.st.clearCheck();
});
}
approval(): void {
this.msg.success(`审批了 ${this.selectedRows.length}`);
}
add(tpl: TemplateRef<{}>): void {
this.modalSrv.create({
nzTitle: '新建规则',
nzContent: tpl,
nzOnOk: () => {
this.loading = true;
this.http.post('/rule', { description: this.description }).subscribe(() => this.getData());
}
});
}
reset(): void {
// wait form reset updated finished
setTimeout(() => this.getData());
}
}

View File

@ -0,0 +1,7 @@
import { Component } from '@angular/core';
@Component({
selector: 'exception-403',
template: ` <exception type="403" style="min-height: 500px; height: 80%;"></exception> `
})
export class Exception403Component {}

View File

@ -0,0 +1,7 @@
import { Component } from '@angular/core';
@Component({
selector: 'exception-404',
template: ` <exception type="404" style="min-height: 500px; height: 80%;"></exception> `
})
export class Exception404Component {}

View File

@ -0,0 +1,7 @@
import { Component } from '@angular/core';
@Component({
selector: 'exception-500',
template: ` <exception type="500" style="min-height: 500px; height: 80%;"></exception> `
})
export class Exception500Component {}

View File

@ -0,0 +1,20 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { Exception403Component } from './403.component';
import { Exception404Component } from './404.component';
import { Exception500Component } from './500.component';
import { ExceptionTriggerComponent } from './trigger.component';
const routes: Routes = [
{ path: '403', component: Exception403Component },
{ path: '404', component: Exception404Component },
{ path: '500', component: Exception500Component },
{ path: 'trigger', component: ExceptionTriggerComponent }
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class ExceptionRoutingModule {}

View File

@ -0,0 +1,19 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { ExceptionModule as DelonExceptionModule } from '@delon/abc/exception';
import { NzButtonModule } from 'ng-zorro-antd/button';
import { NzCardModule } from 'ng-zorro-antd/card';
import { Exception403Component } from './403.component';
import { Exception404Component } from './404.component';
import { Exception500Component } from './500.component';
import { ExceptionRoutingModule } from './exception-routing.module';
import { ExceptionTriggerComponent } from './trigger.component';
const COMPONENTS = [Exception403Component, Exception404Component, Exception500Component, ExceptionTriggerComponent];
@NgModule({
imports: [CommonModule, DelonExceptionModule, NzButtonModule, NzCardModule, ExceptionRoutingModule],
declarations: [...COMPONENTS]
})
export class ExceptionModule {}

View File

@ -0,0 +1,35 @@
import { Component, Inject } from '@angular/core';
import { DA_SERVICE_TOKEN, ITokenService } from '@delon/auth';
import { _HttpClient } from '@delon/theme';
@Component({
selector: 'exception-trigger',
template: `
<div class="pt-lg">
<nz-card>
<button *ngFor="let t of types" (click)="go(t)" nz-button nzDanger>触发{{ t }}</button>
<button nz-button nzType="link" (click)="refresh()">触发刷新Token</button>
</nz-card>
</div>
`
})
export class ExceptionTriggerComponent {
types = [401, 403, 404, 500];
constructor(private http: _HttpClient, @Inject(DA_SERVICE_TOKEN) private tokenService: ITokenService) {}
go(type: number): void {
this.http.get(`/api/${type}`).subscribe();
}
refresh(): void {
this.tokenService.set({ token: 'invalid-token' });
// 必须提供一个后端地址,无法通过 Mock 来模拟
this.http.post(`https://localhost:5001/auth`).subscribe(
res => console.warn('成功', res),
err => {
console.log('最后结果失败', err);
}
);
}
}

View File

@ -0,0 +1,35 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { SocialService } from '@delon/auth';
import { SettingsService } from '@delon/theme';
@Component({
selector: 'app-callback',
template: ``,
providers: [SocialService]
})
export class CallbackComponent implements OnInit {
type = '';
constructor(private socialService: SocialService, private settingsSrv: SettingsService, private route: ActivatedRoute) {}
ngOnInit(): void {
this.type = this.route.snapshot.params.type;
this.mockModel();
}
private mockModel(): void {
const info = {
token: '123456789',
name: 'cipchk',
email: `${this.type}@${this.type}.com`,
id: 10000,
time: +new Date()
};
this.settingsSrv.setUser({
...this.settingsSrv.user,
...info
});
this.socialService.callback(info);
}
}

View File

@ -0,0 +1,21 @@
<div class="ant-card width-lg" style="margin: 0 auto">
<div class="ant-card-body">
<div class="avatar">
<nz-avatar [nzSrc]="user.avatar" nzIcon="user" nzSize="large"></nz-avatar>
</div>
<form nz-form [formGroup]="f" (ngSubmit)="submit()" role="form" class="mt-md">
<nz-form-item>
<nz-form-control nzErrorTip="请输入密码!">
<nz-input-group nzSuffixIcon="lock">
<input type="password" nz-input formControlName="password" />
</nz-input-group>
</nz-form-control>
</nz-form-item>
<nz-row nzType="flex" nzAlign="middle">
<nz-col [nzOffset]="12" [nzSpan]="12" style="text-align: right">
<button nz-button [disabled]="!f.valid" nzType="primary">锁屏</button>
</nz-col>
</nz-row>
</form>
</div>
</div>

View File

@ -0,0 +1,12 @@
:host ::ng-deep {
.ant-card-body {
position: relative;
margin-top: 80px;
}
.avatar {
position: absolute;
top: -20px;
left: 50%;
margin-left: -20px;
}
}

View File

@ -0,0 +1,44 @@
import { Component, Inject } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { DA_SERVICE_TOKEN, ITokenService } from '@delon/auth';
import { SettingsService, User } from '@delon/theme';
@Component({
selector: 'passport-lock',
templateUrl: './lock.component.html',
styleUrls: ['./lock.component.less']
})
export class UserLockComponent {
f: FormGroup;
get user(): User {
return this.settings.user;
}
constructor(
fb: FormBuilder,
@Inject(DA_SERVICE_TOKEN) private tokenService: ITokenService,
private settings: SettingsService,
private router: Router
) {
this.f = fb.group({
password: [null, Validators.required]
});
}
submit(): void {
for (const i in this.f.controls) {
this.f.controls[i].markAsDirty();
this.f.controls[i].updateValueAndValidity();
}
if (this.f.valid) {
console.log('Valid!');
console.log(this.f.value);
this.tokenService.set({
token: '123'
});
this.router.navigate(['dashboard']);
}
}
}

View File

@ -0,0 +1,77 @@
<form nz-form [formGroup]="form" (ngSubmit)="submit()" role="form">
<nz-tabset [nzAnimated]="false" class="tabs" (nzSelectChange)="switch($event)">
<nz-tab nzTitle="账户密码登录">
<nz-alert *ngIf="error" [nzType]="'error'" [nzMessage]="error" [nzShowIcon]="true" class="mb-lg"></nz-alert>
<nz-form-item>
<nz-form-control nzErrorTip="Please enter mobile number, muse be: admin or user">
<nz-input-group nzSize="large" nzPrefixIcon="user">
<input nz-input formControlName="userName" placeholder="username: admin or user" />
</nz-input-group>
</nz-form-control>
</nz-form-item>
<nz-form-item>
<nz-form-control nzErrorTip="Please enter password, muse be: ">
<nz-input-group nzSize="large" nzPrefixIcon="lock">
<input nz-input type="password" formControlName="password" placeholder="password: 随便" />
</nz-input-group>
</nz-form-control>
</nz-form-item>
</nz-tab>
<nz-tab nzTitle="手机号登录">
<nz-form-item>
<nz-form-control [nzErrorTip]="mobileErrorTip">
<nz-input-group nzSize="large" nzPrefixIcon="user">
<input nz-input formControlName="mobile" placeholder="mobile number" />
</nz-input-group>
<ng-template #mobileErrorTip let-i>
<ng-container *ngIf="i.errors.required">
请输入手机号!
</ng-container>
<ng-container *ngIf="i.errors.pattern">
手机号格式错误!
</ng-container>
</ng-template>
</nz-form-control>
</nz-form-item>
<nz-form-item>
<nz-form-control nzErrorTip="请输入验证码!">
<nz-row [nzGutter]="8">
<nz-col [nzSpan]="16">
<nz-input-group nzSize="large" nzPrefixIcon="mail">
<input nz-input formControlName="captcha" placeholder="captcha" />
</nz-input-group>
</nz-col>
<nz-col [nzSpan]="8">
<button type="button" nz-button nzSize="large" (click)="getCaptcha()" [disabled]="count >= 0" nzBlock
[nzLoading]="loading">
{{ count ? count + 's' : '获取验证码' }}
</button>
</nz-col>
</nz-row>
</nz-form-control>
</nz-form-item>
</nz-tab>
</nz-tabset>
<nz-form-item>
<nz-col [nzSpan]="12">
<label nz-checkbox formControlName="remember">自动登录</label>
</nz-col>
<nz-col [nzSpan]="12" class="text-right">
<a class="forgot" routerLink="/passport/register">忘记密码</a>
</nz-col>
</nz-form-item>
<nz-form-item>
<button nz-button type="submit" nzType="primary" nzSize="large" [nzLoading]="loading" nzBlock>
登录
</button>
</nz-form-item>
</form>
<div class="other">
其他登录方式
<i nz-tooltip nzTooltipTitle="in fact Auth0 via window" (click)="open('auth0', 'window')" nz-icon
nzType="alipay-circle" class="icon"></i>
<i nz-tooltip nzTooltipTitle="in fact Github via redirect" (click)="open('github')" nz-icon nzType="taobao-circle"
class="icon"></i>
<i (click)="open('weibo', 'window')" nz-icon nzType="weibo-circle" class="icon"></i>
<a class="register" routerLink="/passport/register">注册账户</a>
</div>

View File

@ -0,0 +1,53 @@
@import '~@delon/theme/index';
:host {
display: block;
width: 368px;
margin: 0 auto;
::ng-deep {
.ant-tabs .ant-tabs-bar {
margin-bottom: 24px;
text-align: center;
border-bottom: 0;
}
.ant-tabs-tab {
font-size: 16px;
line-height: 24px;
}
.ant-input-affix-wrapper .ant-input:not(:first-child) {
padding-left: 4px;
}
.icon {
margin-left: 16px;
color: rgba(0, 0, 0, 0.2);
font-size: 24px;
vertical-align: middle;
cursor: pointer;
transition: color 0.3s;
&:hover {
color: @primary-color;
}
}
.other {
margin-top: 24px;
line-height: 22px;
text-align: left;
nz-tooltip {
vertical-align: middle;
}
.register {
float: right;
}
}
}
}
[data-theme='dark'] {
:host ::ng-deep {
.icon {
color: rgba(255, 255, 255, 0.2);
&:hover {
color: #fff;
}
}
}
}

View File

@ -0,0 +1,213 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, OnDestroy, Optional } from '@angular/core';
import { AbstractControl, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { StartupService } from '@core';
import { ReuseTabService } from '@delon/abc/reuse-tab';
import { DA_SERVICE_TOKEN, ITokenService, SocialOpenType, SocialService } from '@delon/auth';
import { SettingsService, _HttpClient } from '@delon/theme';
import { environment } from '@env/environment';
import { NzTabChangeEvent } from 'ng-zorro-antd/tabs';
import { finalize } from 'rxjs/operators';
@Component({
selector: 'passport-login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.less'],
providers: [SocialService],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserLoginComponent implements OnDestroy {
constructor(
fb: FormBuilder,
private router: Router,
private settingsService: SettingsService,
private socialService: SocialService,
@Optional()
@Inject(ReuseTabService)
private reuseTabService: ReuseTabService,
@Inject(DA_SERVICE_TOKEN) private tokenService: ITokenService,
private startupSrv: StartupService,
private http: _HttpClient,
private cdr: ChangeDetectorRef
) {
this.form = fb.group({
userName: [null, [Validators.required]],
password: [null, [Validators.required]],
mobile: [null, [Validators.required, Validators.pattern(/^1\d{10}$/)]],
captcha: [null, [Validators.required]],
remember: [true]
});
}
// #region fields
get userName(): AbstractControl {
return this.form.controls.userName;
}
get password(): AbstractControl {
return this.form.controls.password;
}
get mobile(): AbstractControl {
return this.form.controls.mobile;
}
get captcha(): AbstractControl {
return this.form.controls.captcha;
}
form: FormGroup;
error = '';
type = 0;
loading = false;
// #region get captcha
count = 0;
interval$: any;
// #endregion
switch({ index }: NzTabChangeEvent): void {
this.type = index!;
}
getCaptcha(): void {
if (this.mobile.invalid) {
this.mobile.markAsDirty({ onlySelf: true });
this.mobile.updateValueAndValidity({ onlySelf: true });
return;
}
this.count = 59;
this.interval$ = setInterval(() => {
this.count -= 1;
if (this.count <= 0) {
clearInterval(this.interval$);
}
}, 1000);
}
// #endregion
submit(): void {
this.error = '';
if (this.type === 0) {
this.userName.markAsDirty();
this.userName.updateValueAndValidity();
this.password.markAsDirty();
this.password.updateValueAndValidity();
if (this.userName.invalid || this.password.invalid) {
return;
}
} else {
this.mobile.markAsDirty();
this.mobile.updateValueAndValidity();
this.captcha.markAsDirty();
this.captcha.updateValueAndValidity();
if (this.mobile.invalid || this.captcha.invalid) {
return;
}
}
// 默认配置中对所有HTTP请求都会强制 [校验](https://ng-alain.com/auth/getting-started) 用户 Token
// 然一般来说登录请求不需要校验因此可以在请求URL加上`/login?_allow_anonymous=true` 表示不触发用户 Token 校验
this.loading = true;
this.cdr.detectChanges();
this.befaultLogin();
return;
this.http
.post('/login/account?_allow_anonymous=true', {
type: this.type,
userName: this.userName.value,
password: this.password.value
})
.pipe(
finalize(() => {
this.loading = true;
this.cdr.detectChanges();
})
)
.subscribe(res => {
if (res.msg !== 'ok') {
this.error = res.msg;
this.cdr.detectChanges();
return;
}
// 清空路由复用信息
this.reuseTabService.clear();
// 设置用户Token信息
// TODO: Mock expired value
res.user.expired = +new Date() + 1000 * 60 * 5;
this.tokenService.set(res.user);
// 重新获取 StartupService 内容,我们始终认为应用信息一般都会受当前用户授权范围而影响
this.startupSrv.load().then(() => {
let url = this.tokenService.referrer!.url || '/';
if (url.includes('/passport')) {
url = '/';
}
this.router.navigateByUrl(url);
});
});
}
befaultLogin() {
// 清空路由复用信息
this.reuseTabService.clear();
// 设置用户Token信息
// TODO: Mock expired value
// 重新获取 StartupService 内容,我们始终认为应用信息一般都会受当前用户授权范围而影响
this.startupSrv.load().then(() => {
let url = this.tokenService.referrer!.url || '/';
if (url.includes('/passport')) {
url = '/';
}
this.router.navigateByUrl(url);
});
}
// #region social
open(type: string, openType: SocialOpenType = 'href'): void {
let url = ``;
let callback = ``;
if (environment.production) {
callback = `https://ng-alain.github.io/ng-alain/#/passport/callback/${type}`;
} else {
callback = `http://localhost:4200/#/passport/callback/${type}`;
}
switch (type) {
case 'auth0':
url = `//cipchk.auth0.com/login?client=8gcNydIDzGBYxzqV0Vm1CX_RXH-wsWo5&redirect_uri=${decodeURIComponent(callback)}`;
break;
case 'github':
url = `//github.com/login/oauth/authorize?client_id=9d6baae4b04a23fcafa2&response_type=code&redirect_uri=${decodeURIComponent(
callback
)}`;
break;
case 'weibo':
url = `https://api.weibo.com/oauth2/authorize?client_id=1239507802&response_type=code&redirect_uri=${decodeURIComponent(callback)}`;
break;
}
if (openType === 'window') {
this.socialService
.login(url, '/', {
type: 'window'
})
.subscribe(res => {
if (res) {
this.settingsService.setUser(res);
this.router.navigateByUrl('/');
}
});
} else {
this.socialService.login(url, '/', {
type: 'href'
});
}
}
// #endregion
ngOnDestroy(): void {
if (this.interval$) {
clearInterval(this.interval$);
}
}
}

View File

@ -0,0 +1,56 @@
<!-- <pro-langs class="pro-passport__langs"></pro-langs> -->
<div class="ant-col-lg-16 pro-passport__bg" style="background-image: url('./assets/tmp/img-big/bg-1.jpeg')">
<div class="pro-passport__bg-overlay"></div>
<div class="text">
<h1>Work with us</h1>
<p>Our researchers are embedded in teams across computer science, to discover, invent, and build at the largest
scale.</p>
</div>
</div>
<div class="ant-col-lg-8 pro-passport__form">
<div class="pro-passport__form-logo"><img src="./assets/logo-color.svg" /></div>
<h3 class="pro-passport__form-title">注册</h3>
<form nz-form #f="ngForm" nzLayout="vertical" [formGroup]="form" (ngSubmit)="submit()" role="form" se-container>
<nz-alert *ngIf="error" [nzType]="'error'" [nzMessage]="error" [nzShowIcon]="true" class="mb-lg"></nz-alert>
<se label="手机号" required error="手机号格式错误!">
<nz-input-group nzSize="large" [nzAddOnBefore]="addOnBeforeTemplate">
<ng-template #addOnBeforeTemplate>
<nz-select formControlName="mobilePrefix" style="width: 80px">
<nz-option [nzLabel]="'+86'" [nzValue]="'+86'"></nz-option>
<nz-option [nzLabel]="'+87'" [nzValue]="'+87'"></nz-option>
</nz-select>
</ng-template>
<input formControlName="mobile" nz-input type="tel" placeholder="Phone number" />
</nz-input-group>
</se>
<se label="请输入验证码!" required error="请输入验证码!">
<nz-row [nzGutter]="8">
<nz-col [nzSpan]="12">
<nz-input-group nzSize="large" nzAddonBeforeIcon="anticon anticon-mail">
<input nz-input formControlName="captcha" type="tel" placeholder="Captcha" />
</nz-input-group>
</nz-col>
<nz-col [nzSpan]="12">
<button type="button" nz-button nzSize="large" (click)="getCaptcha()" [disabled]="count > 0" nzBlock
[nzLoading]="http.loading">
{{ count ? count + 's' :'获取验证码' }}
</button>
</nz-col>
</nz-row>
</se>
<se label="密码" required error="请输入密码!">
<input nz-input type="password" nzSize="large" formControlName="password" placeholder="Password" />
</se>
<se>
<div class="flex-center-between">
<button nz-button nzType="primary" nzSize="large" type="submit" [disabled]="f.invalid"
[nzLoading]="http.loading">
注册
</button>
<a routerLink="/passport/login">
使用已有账户登录
</a>
</div>
</se>
</form>
</div>

View File

@ -0,0 +1,28 @@
@import '~@delon/theme/index';
:host ::ng-deep {
.pro-passport__bg {
position: relative;
display: flex;
align-items: center;
padding: 48px;
@media (max-width: @screen-md-max) {
display: none !important;
}
.text {
position: relative;
padding: 0 64px;
color: #fff;
h1 {
margin-bottom: 24px;
color: #fff;
font-weight: 900;
font-size: 56px;
}
p {
font-size: 22px;
line-height: 32px;
}
}
}
}

View File

@ -0,0 +1,78 @@
import { Component, OnDestroy } from '@angular/core';
import { AbstractControl, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { _HttpClient } from '@delon/theme';
import { NzMessageService } from 'ng-zorro-antd/message';
@Component({
selector: 'passport-login2',
templateUrl: './login2.component.html',
styleUrls: ['./login2.component.less'],
host: {
'[class.ant-row]': 'true',
'[class.pro-passport]': 'true'
}
})
export class UserLogin2Component implements OnDestroy {
form: FormGroup;
error = '';
constructor(fb: FormBuilder, private router: Router, private msg: NzMessageService, public http: _HttpClient) {
this.form = fb.group({
mobilePrefix: ['+86'],
mobile: [null, [Validators.required, Validators.pattern(/^1\d{10}$/)]],
captcha: [null, [Validators.required]],
password: [null, [Validators.required, Validators.minLength(6)]]
});
}
// #region fields
get password(): AbstractControl {
return this.form.controls.password;
}
get mobile(): AbstractControl {
return this.form.controls.mobile;
}
get captcha(): AbstractControl {
return this.form.controls.captcha;
}
// #endregion
// #region get captcha
count = 0;
interval$: any;
getCaptcha(): void {
if (this.mobile.invalid) {
this.mobile.markAsDirty({ onlySelf: true });
this.mobile.updateValueAndValidity({ onlySelf: true });
return;
}
this.count = 59;
this.interval$ = setInterval(() => {
this.count -= 1;
if (this.count <= 0) {
clearInterval(this.interval$);
}
}, 1000);
}
// #endregion
submit(): void {
this.error = '';
const data = this.form.value;
this.http.post('/register', data).subscribe(() => {
this.router.navigate(['passport', 'register-result'], { queryParams: { email: data.mail } });
});
}
ngOnDestroy(): void {
if (this.interval$) {
clearInterval(this.interval$);
}
}
}

View File

@ -0,0 +1,51 @@
<!-- <pro-langs class="pro-passport__langs"></pro-langs> -->
<div class="pro-passport__bg width-100" style="background-image: url('./assets/tmp/img-big/bg-2.jpeg')">
<div class="pro-passport__bg-overlay"></div>
<div class="pro-passport__form">
<div class="pro-passport__form-logo"><img src="./assets/logo-color.svg" /></div>
<h3 class="pro-passport__form-title">注册</h3>
<form nz-form #f="ngForm" nzLayout="vertical" [formGroup]="form" (ngSubmit)="submit()" role="form" se-container>
<nz-alert *ngIf="error" [nzType]="'error'" [nzMessage]="error" [nzShowIcon]="true" class="mb-lg"></nz-alert>
<se label="手机号" required error="手机号格式错误!">
<nz-input-group nzSize="large" [nzAddOnBefore]="addOnBeforeTemplate">
<ng-template #addOnBeforeTemplate>
<nz-select formControlName="mobilePrefix" style="width: 80px">
<nz-option [nzLabel]="'+86'" [nzValue]="'+86'"></nz-option>
<nz-option [nzLabel]="'+87'" [nzValue]="'+87'"></nz-option>
</nz-select>
</ng-template>
<input formControlName="mobile" nz-input type="tel" placeholder="Phone number" />
</nz-input-group>
</se>
<se label="验证码" required error="请输入验证码!">
<nz-row [nzGutter]="8">
<nz-col [nzSpan]="12">
<nz-input-group nzSize="large" nzAddonBeforeIcon="anticon anticon-mail">
<input nz-input formControlName="captcha" type="tel" placeholder="Captcha" />
</nz-input-group>
</nz-col>
<nz-col [nzSpan]="12">
<button type="button" nz-button nzSize="large" (click)="getCaptcha()" [disabled]="count > 0" nzBlock
[nzLoading]="http.loading">
{{ count ? count + 's' : '获取验证码' }}
</button>
</nz-col>
</nz-row>
</se>
<se label="密码" required error="请输入密码!">
<input nz-input type="password" nzSize="large" formControlName="password" placeholder="Password" />
</se>
<se>
<div class="flex-center-between">
<button nz-button nzType="primary" nzSize="large" type="submit" [disabled]="f.invalid"
[nzLoading]="http.loading">
注册
</button>
<a routerLink="/passport/login">
使用已有账户登录
</a>
</div>
</se>
</form>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More