[web-app] ng-alain模版工程初始化

This commit is contained in:
tomsun28
2021-11-27 22:21:52 +08:00
parent 39a9a8eb7c
commit cdee4add2a
113 changed files with 9004 additions and 28649 deletions

View File

@@ -1,13 +0,0 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: '/welcome' },
{ path: 'welcome', loadChildren: () => import('./pages/welcome/welcome.module').then(m => m.WelcomeModule) }
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }

View File

@@ -1,54 +0,0 @@
<nz-layout class="app-layout">
<nz-sider class="menu-sidebar"
nzCollapsible
nzWidth="256px"
nzBreakpoint="md"
[(nzCollapsed)]="isCollapsed"
[nzTrigger]="null">
<div class="sidebar-logo">
<a href="https://ng.ant.design/" target="_blank">
<img src="https://ng.ant.design/assets/img/logo.svg" alt="logo">
<h1>Ant Design Of Angular</h1>
</a>
</div>
<ul nz-menu nzTheme="dark" nzMode="inline" [nzInlineCollapsed]="isCollapsed">
<li nz-submenu nzOpen nzTitle="Dashboard" nzIcon="dashboard">
<ul>
<li nz-menu-item nzMatchRouter>
<a routerLink="/welcome">Welcome</a>
</li>
<li nz-menu-item nzMatchRouter>
<a>Monitor</a>
</li>
<li nz-menu-item nzMatchRouter>
<a>Workplace</a>
</li>
</ul>
</li>
<li nz-submenu nzOpen nzTitle="Form" nzIcon="form">
<ul>
<li nz-menu-item nzMatchRouter>
<a>Basic Form</a>
</li>
</ul>
</li>
</ul>
</nz-sider>
<nz-layout>
<nz-header>
<div class="app-header">
<span class="header-trigger" (click)="isCollapsed = !isCollapsed">
<i class="trigger"
nz-icon
[nzType]="isCollapsed ? 'menu-unfold' : 'menu-fold'"
></i>
</span>
</div>
</nz-header>
<nz-content>
<div class="inner-content">
<router-outlet></router-outlet>
</div>
</nz-content>
</nz-layout>
</nz-layout>

View File

@@ -1,80 +0,0 @@
:host {
display: flex;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.app-layout {
height: 100vh;
}
.menu-sidebar {
position: relative;
z-index: 10;
min-height: 100vh;
box-shadow: 2px 0 6px rgba(0,21,41,.35);
}
.header-trigger {
height: 64px;
padding: 20px 24px;
font-size: 20px;
cursor: pointer;
transition: all .3s,padding 0s;
}
.trigger:hover {
color: #1890ff;
}
.sidebar-logo {
position: relative;
height: 64px;
padding-left: 24px;
overflow: hidden;
line-height: 64px;
background: #001529;
transition: all .3s;
}
.sidebar-logo img {
display: inline-block;
height: 32px;
width: 32px;
vertical-align: middle;
}
.sidebar-logo h1 {
display: inline-block;
margin: 0 0 0 20px;
color: #fff;
font-weight: 600;
font-size: 14px;
font-family: Avenir,Helvetica Neue,Arial,Helvetica,sans-serif;
vertical-align: middle;
}
nz-header {
padding: 0;
width: 100%;
z-index: 2;
}
.app-header {
position: relative;
height: 64px;
padding: 0;
background: #fff;
box-shadow: 0 1px 4px rgba(0,21,41,.08);
}
nz-content {
margin: 24px;
}
.inner-content {
padding: 24px;
background: #fff;
height: 100%;
}

View File

@@ -1,35 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
RouterTestingModule
],
declarations: [
AppComponent
],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it(`should have as title 'web-app'`, () => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.componentInstance;
expect(app.title).toEqual('web-app');
});
it('should render title', () => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.content span')?.textContent).toContain('web-app app is running!');
});
});

View File

@@ -1,10 +1,46 @@
import { Component } from '@angular/core';
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',
templateUrl: './app.component.html',
styleUrls: ['./app.component.less']
template: ` <router-outlet></router-outlet> `
})
export class AppComponent {
isCollapsed = false;
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();
}
});
}
}

View File

@@ -1,20 +1,88 @@
import { NgModule } from '@angular/core';
/* eslint-disable import/order */
/* eslint-disable import/no-duplicates */
import { HttpClient, HttpClientModule } from '@angular/common/http';
import { APP_INITIALIZER, Injector, LOCALE_ID, NgModule, Type } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { NZ_I18N } from 'ng-zorro-antd/i18n';
import { en_US } from 'ng-zorro-antd/i18n';
import { registerLocaleData } from '@angular/common';
import en from '@angular/common/locales/en';
import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { IconsProviderModule } from './icons-provider.module';
import { NzLayoutModule } from 'ng-zorro-antd/layout';
import { NzMenuModule } from 'ng-zorro-antd/menu';
import { NzMessageModule } from 'ng-zorro-antd/message';
import { NzNotificationModule } from 'ng-zorro-antd/notification';
import { Observable } from 'rxjs';
registerLocaleData(en);
// #region default language
// Reference: https://ng-alain.com/docs/i18n
import { default as ngLang } from '@angular/common/locales/zh';
import { DELON_LOCALE, zh_CN as delonLang } from '@delon/theme';
import { zhCN as dateLang } from 'date-fns/locale';
import { NZ_DATE_LOCALE, NZ_I18N, zh_CN as zorroLang } from 'ng-zorro-antd/i18n';
const LANG = {
abbr: 'zh',
ng: ngLang,
zorro: zorroLang,
date: dateLang,
delon: delonLang,
};
// register angular
import { registerLocaleData } from '@angular/common';
registerLocaleData(LANG.ng, LANG.abbr);
const LANG_PROVIDES = [
{ provide: LOCALE_ID, useValue: LANG.abbr },
{ provide: NZ_I18N, useValue: LANG.zorro },
{ provide: NZ_DATE_LOCALE, useValue: LANG.date },
{ provide: DELON_LOCALE, useValue: LANG.delon },
];
// #endregion
// #region i18n services
import { ALAIN_I18N_TOKEN } from '@delon/theme';
import { I18NService } from '@core';
const I18NSERVICE_PROVIDES = [
{ provide: ALAIN_I18N_TOKEN, useClass: I18NService, multi: false }
];
// #region
// #region JSON Schema form (using @delon/form)
import { JsonSchemaModule } from '@shared';
const FORM_MODULES = [ JsonSchemaModule ];
// #endregion
// #region Http Interceptors
import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { DefaultInterceptor } from '@core';
import { SimpleInterceptor } from '@delon/auth';
const INTERCEPTOR_PROVIDES = [
{ provide: HTTP_INTERCEPTORS, useClass: SimpleInterceptor, multi: true},
{ provide: HTTP_INTERCEPTORS, useClass: DefaultInterceptor, multi: true}
];
// #endregion
// #region global third module
const GLOBAL_THIRD_MODULES: Array<Type<void>> = [];
// #endregion
// #region Startup Service
import { StartupService } from '@core';
export function StartupServiceFactory(startupService: StartupService): () => Observable<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/st-widget/st-widget.module';
@NgModule({
declarations: [
@@ -22,15 +90,25 @@ registerLocaleData(en);
],
imports: [
BrowserModule,
AppRoutingModule,
FormsModule,
HttpClientModule,
BrowserAnimationsModule,
IconsProviderModule,
NzLayoutModule,
NzMenuModule
HttpClientModule,
GlobalConfigModule.forRoot(),
CoreModule,
SharedModule,
LayoutModule,
RoutesModule,
STWidgetModule,
NzMessageModule,
NzNotificationModule,
...FORM_MODULES,
...GLOBAL_THIRD_MODULES
],
providers: [
...LANG_PROVIDES,
...INTERCEPTOR_PROVIDES,
...I18NSERVICE_PROVIDES,
...APPINIT_PROVIDES
],
providers: [{ provide: NZ_I18N, useValue: en_US }],
bootstrap: [AppComponent]
})
export class AppModule { }

View File

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

View File

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

View File

@@ -0,0 +1,84 @@
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { TestBed, TestBedStatic } from '@angular/core/testing';
import { DelonLocaleService, SettingsService } from '@delon/theme';
import { NzSafeAny } from 'ng-zorro-antd/core/types';
import { NzI18nService } from 'ng-zorro-antd/i18n';
import { of } from 'rxjs';
import { I18NService } from './i18n.service';
describe('Service: I18n', () => {
let injector: TestBedStatic;
let srv: I18NService;
const MockSettingsService: NzSafeAny = {
layout: {
lang: null
}
};
const MockNzI18nService = {
setLocale: () => {},
setDateLocale: () => {}
};
const MockDelonLocaleService = {
setLocale: () => {}
};
const MockTranslateService = {
getBrowserLang: jasmine.createSpy('getBrowserLang'),
addLangs: () => {},
setLocale: () => {},
getDefaultLang: () => '',
use: (lang: string) => of(lang),
instant: jasmine.createSpy('instant')
};
function genModule(): void {
injector = TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
I18NService,
{ provide: SettingsService, useValue: MockSettingsService },
{ provide: NzI18nService, useValue: MockNzI18nService },
{ provide: DelonLocaleService, useValue: MockDelonLocaleService }
]
});
srv = TestBed.inject(I18NService);
}
it('should working', () => {
spyOnProperty(navigator, 'languages').and.returnValue(['zh-CN']);
genModule();
expect(srv).toBeTruthy();
expect(srv.defaultLang).toBe('zh-CN');
srv.fanyi('a');
srv.fanyi('a', {});
});
it('should be used layout as default language', () => {
MockSettingsService.layout.lang = 'en-US';
const navSpy = spyOnProperty(navigator, 'languages');
genModule();
expect(navSpy).not.toHaveBeenCalled();
expect(srv.defaultLang).toBe('en-US');
MockSettingsService.layout.lang = null;
});
it('should be used browser as default language', () => {
spyOnProperty(navigator, 'languages').and.returnValue(['zh-TW']);
genModule();
expect(srv.defaultLang).toBe('zh-TW');
});
it('should be use default language when the browser language is not in the list', () => {
spyOnProperty(navigator, 'languages').and.returnValue(['es-419']);
genModule();
expect(srv.defaultLang).toBe('zh-CN');
});
it('should be trigger notify when changed language', () => {
genModule();
srv.use('en-US', {});
srv.change.subscribe(lang => {
expect(lang).toBe('en-US');
});
});
});

View File

@@ -0,0 +1,116 @@
// 请参考https://ng-alain.com/docs/i18n
import { Platform } from '@angular/cdk/platform';
import { registerLocaleData } from '@angular/common';
import ngEn from '@angular/common/locales/en';
import ngZh from '@angular/common/locales/zh';
import ngZhTw from '@angular/common/locales/zh-Hant';
import { Injectable } from '@angular/core';
import {
DelonLocaleService,
en_US as delonEnUS,
SettingsService,
zh_CN as delonZhCn,
zh_TW as delonZhTw,
_HttpClient,
AlainI18nBaseService
} from '@delon/theme';
import { AlainConfigService } from '@delon/util/config';
import { enUS as dfEn, zhCN as dfZhCn, zhTW as dfZhTw } from 'date-fns/locale';
import { NzSafeAny } from 'ng-zorro-antd/core/types';
import { en_US as zorroEnUS, NzI18nService, zh_CN as zorroZhCN, zh_TW as zorroZhTW } from 'ng-zorro-antd/i18n';
import { Observable } from 'rxjs';
interface LangConfigData {
abbr: string;
text: string;
ng: NzSafeAny;
zorro: NzSafeAny;
date: NzSafeAny;
delon: NzSafeAny;
}
const DEFAULT = 'zh-CN';
const LANGS: { [key: string]: LangConfigData } = {
'zh-CN': {
text: '简体中文',
ng: ngZh,
zorro: zorroZhCN,
date: dfZhCn,
delon: delonZhCn,
abbr: '🇨🇳'
},
'zh-TW': {
text: '繁体中文',
ng: ngZhTw,
zorro: zorroZhTW,
date: dfZhTw,
delon: delonZhTw,
abbr: '🇭🇰'
},
'en-US': {
text: 'English',
ng: ngEn,
zorro: zorroEnUS,
date: dfEn,
delon: delonEnUS,
abbr: '🇬🇧'
}
};
@Injectable({ providedIn: 'root' })
export class I18NService extends AlainI18nBaseService {
protected _defaultLang = DEFAULT;
private _langs = Object.keys(LANGS).map(code => {
const item = LANGS[code];
return { code, text: item.text, abbr: item.abbr };
});
constructor(
private http: _HttpClient,
private settings: SettingsService,
private nzI18nService: NzI18nService,
private delonLocaleService: DelonLocaleService,
private platform: Platform,
cogSrv: AlainConfigService
) {
super(cogSrv);
const defaultLang = this.getDefaultLang();
this._defaultLang = this._langs.findIndex(w => w.code === defaultLang) === -1 ? DEFAULT : defaultLang;
}
private getDefaultLang(): string {
if (!this.platform.isBrowser) {
return DEFAULT;
}
if (this.settings.layout.lang) {
return this.settings.layout.lang;
}
let res = (navigator.languages ? navigator.languages[0] : null) || navigator.language;
const arr = res.split('-');
return arr.length <= 1 ? res : `${arr[0]}-${arr[1].toUpperCase()}`;
}
loadLangData(lang: string): Observable<NzSafeAny> {
return this.http.get(`assets/tmp/i18n/${lang}.json`);
}
use(lang: string, data: Record<string, unknown>): void {
if (this._currentLang === lang) return;
this._data = this.flatData(data, []);
const item = LANGS[lang];
registerLocaleData(item.ng);
this.nzI18nService.setLocale(item.zorro);
this.nzI18nService.setDateLocale(item.date);
this.delonLocaleService.setLocale(item.delon);
this._currentLang = lang;
this._change$.next(lang);
}
getLangs(): Array<{ code: string; text: string; abbr: string }> {
return this._langs;
}
}

View File

@@ -0,0 +1,4 @@
export * from './i18n/i18n.service';
export * from './module-import-guard';
export * from './net/default.interceptor';
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 { ALAIN_I18N_TOKEN, _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}?url=${req.urlWithParams}`);
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,127 @@
import { Injectable, Inject } from '@angular/core';
import { Router } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { DA_SERVICE_TOKEN, ITokenService } from '@delon/auth';
import { ALAIN_I18N_TOKEN, MenuService, SettingsService, TitleService } from '@delon/theme';
import { ACLService } from '@delon/acl';
import { I18NService } from '../i18n/i18n.service';
import { Observable, zip, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import type { NzSafeAny } from 'ng-zorro-antd/core/types';
import { NzIconService } from 'ng-zorro-antd/icon';
import { ICONS } from '../../../style-icons';
import { ICONS_AUTO } from '../../../style-icons-auto';
/**
* 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,
@Inject(ALAIN_I18N_TOKEN) private i18n: I18NService,
private settingService: SettingsService,
private aclService: ACLService,
private titleService: TitleService,
@Inject(DA_SERVICE_TOKEN) private tokenService: ITokenService,
private httpClient: HttpClient,
private router: Router
) {
iconSrv.addIcon(...ICONS_AUTO, ...ICONS);
}
private viaHttp(): Observable<void> {
const defaultLang = this.i18n.defaultLang;
return zip(this.i18n.loadLangData(defaultLang), this.httpClient.get('assets/tmp/app-data.json')).pipe(
catchError((res: NzSafeAny) => {
console.warn(`StartupService.load: Network request failed`, res);
setTimeout(() => this.router.navigateByUrl(`/exception/500`));
return [];
}),
map(([langData, appData]: [Record<string, string>, NzSafeAny]) => {
// setting language data
this.i18n.use(defaultLang, langData);
// Application data
// Application information: including site name, description, year
this.settingService.setApp(appData.app);
// User information: including name, avatar, email address
this.settingService.setUser(appData.user);
// ACL: Set the permissions to full, https://ng-alain.com/acl/getting-started
this.aclService.setFull(true);
// Menu data, https://ng-alain.com/theme/menu
this.menuService.add(appData.menu);
// Can be set page suffix title, https://ng-alain.com/theme/title
this.titleService.suffix = appData.app.name;
})
);
}
private viaMockI18n(): Observable<void> {
const defaultLang = this.i18n.defaultLang;
return this.i18n.loadLangData(defaultLang).pipe(
map((langData: NzSafeAny) => {
this.i18n.use(defaultLang, langData);
this.viaMock();
})
);
}
private viaMock(): Observable<void> {
// const tokenData = this.tokenService.get();
// if (!tokenData.token) {
// this.router.navigateByUrl(this.tokenService.login_url!);
// return;
// }
// mock
const app: any = {
name: `HertzBeat`,
description: `面向开发者,易用友好的高性能监控云服务`
};
const user: any = {
name: 'Admin',
avatar: './assets/tmp/img/avatar.svg',
email: 'tomsun28@outlook.com',
token: '123456789'
};
// Application information: including site name, description, year
this.settingService.setApp(app);
// User information: including name, avatar, email address
this.settingService.setUser(user);
// ACL: Set the permissions to full, https://ng-alain.com/acl/getting-started
this.aclService.setFull(true);
// Menu data, https://ng-alain.com/theme/menu
this.menuService.add([
{
text: 'Main',
group: true,
children: [
{
text: 'Dashboard',
link: '/dashboard',
icon: { type: 'icon', value: 'appstore' }
}
]
}
]);
// Can be set page suffix title, https://ng-alain.com/theme/title
this.titleService.suffix = app.name;
return of();
}
load(): Observable<void> {
// http
// return this.viaHttp();
// mock: Dont use it in a production environment. ViaMock is just to simulate some data to make the scaffolding work normally
// mock请勿在生产环境中这么使用viaMock 单纯只是为了模拟一些数据使脚手架一开始能正常运行
return this.viaMockI18n();
}
}

View File

@@ -0,0 +1,77 @@
/* eslint-disable import/order */
import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core';
import { DelonACLModule } from '@delon/acl';
import { AlainThemeModule } from '@delon/theme';
import { AlainConfig, ALAIN_CONFIG } from '@delon/util/config';
import { throwIfAlreadyLoaded } from '@core';
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' },
lodop: {
license: `A59B099A586B3851E0F0D7FDBF37B603`,
licenseA: `C94CEE276DB2187AE6B65D56B3FC2848`
},
auth: { login_url: '/passport/login' }
};
const alainModules: any[] = [AlainThemeModule.forRoot(), DelonACLModule.forRoot()];
const alainProvides = [{ provide: ALAIN_CONFIG, useValue: alainConfig }];
// #region reuse-tab
/**
* 若需要[路由复用](https://ng-alain.com/components/reuse-tab)需要:
* 1、在 `shared-delon.module.ts` 导入 `ReuseTabModule` 模块
* 2、注册 `RouteReuseStrategy`
* 3、在 `src/app/layout/default/default.component.html` 修改:
* ```html
* <section class="alain-default__content">
* <reuse-tab #reuseTab></reuse-tab>
* <router-outlet (activate)="reuseTab.activate($event)"></router-outlet>
* </section>
* ```
*/
// import { RouteReuseStrategy } from '@angular/router';
// import { ReuseTabService, ReuseTabStrategy } from '@delon/abc/reuse-tab';
// 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) {
throwIfAlreadyLoaded(parentModule, 'GlobalConfigModule');
}
static forRoot(): ModuleWithProviders<GlobalConfigModule> {
return {
ngModule: GlobalConfigModule,
providers: [...alainProvides, ...zorroProvides]
};
}
}

View File

@@ -0,0 +1,3 @@
### layout 页面整体布局
**作用:** 规范统一类似功能页面的统一样式布局。

View File

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

View File

@@ -0,0 +1,85 @@
import { Component } from '@angular/core';
import { SettingsService, User } from '@delon/theme';
import { LayoutDefaultOptions } from '@delon/theme/layout-default';
import { environment } from '@env/environment';
@Component({
selector: 'layout-basic',
template: `
<layout-default [options]="options" [asideUser]="asideUserTpl" [content]="contentTpl" [customError]="null">
<layout-default-header-item direction="left">
<a layout-default-header-item-trigger href="//github.com/usthe" target="_blank">
<i nz-icon nzType="github"></i>
</a>
</layout-default-header-item>
<layout-default-header-item direction="left" hidden="mobile">
<a layout-default-header-item-trigger routerLink="/passport/lock">
<i nz-icon nzType="lock"></i>
</a>
</layout-default-header-item>
<layout-default-header-item direction="left" hidden="pc">
<div layout-default-header-item-trigger (click)="searchToggleStatus = !searchToggleStatus">
<i nz-icon nzType="search"></i>
</div>
</layout-default-header-item>
<layout-default-header-item direction="middle">
<header-search class="alain-default__search" [toggleChange]="searchToggleStatus"></header-search>
</layout-default-header-item>
<layout-default-header-item direction="right" hidden="mobile">
<div layout-default-header-item-trigger nz-dropdown [nzDropdownMenu]="settingsMenu" nzTrigger="click" nzPlacement="bottomRight">
<i nz-icon nzType="setting"></i>
</div>
<nz-dropdown-menu #settingsMenu="nzDropdownMenu">
<div nz-menu style="width: 200px;">
<div nz-menu-item>
<header-fullscreen></header-fullscreen>
</div>
<div nz-menu-item>
<header-clear-storage></header-clear-storage>
</div>
<div nz-menu-item>
<header-i18n></header-i18n>
</div>
</div>
</nz-dropdown-menu>
</layout-default-header-item>
<layout-default-header-item direction="right">
<header-user></header-user>
</layout-default-header-item>
<ng-template #asideUserTpl>
<div nz-dropdown nzTrigger="click" [nzDropdownMenu]="userMenu" class="alain-default__aside-user">
<nz-avatar class="alain-default__aside-user-avatar" [nzSrc]="user.avatar"></nz-avatar>
<div class="alain-default__aside-user-info">
<strong>{{ user.name }}</strong>
<p class="mb0">{{ user.email }}</p>
</div>
</div>
<nz-dropdown-menu #userMenu="nzDropdownMenu">
<ul nz-menu>
<li nz-menu-item routerLink="/pro/account/center">{{ 'menu.account.center' | i18n }}</li>
<li nz-menu-item routerLink="/pro/account/settings">{{ 'menu.account.settings' | i18n }}</li>
</ul>
</nz-dropdown-menu>
</ng-template>
<ng-template #contentTpl>
<router-outlet></router-outlet>
</ng-template>
</layout-default>
<setting-drawer *ngIf="showSettingDrawer"></setting-drawer>
<theme-btn></theme-btn>
`,
})
export class LayoutBasicComponent {
options: LayoutDefaultOptions = {
logoExpanded: `./assets/logo-color.svg`,
logoCollapsed: `./assets/logo.svg`,
};
searchToggleStatus = false;
showSettingDrawer = !environment.production;
get user(): User {
return this.settings.user;
}
constructor(private settings: SettingsService) {}
}

View File

@@ -0,0 +1,29 @@
import { ChangeDetectionStrategy, Component, HostListener } from '@angular/core';
import { NzMessageService } from 'ng-zorro-antd/message';
import { NzModalService } from 'ng-zorro-antd/modal';
@Component({
selector: 'header-clear-storage',
template: `
<i nz-icon nzType="tool"></i>
{{ 'menu.clear.local.storage' | i18n }}
`,
host: {
'[class.d-block]': 'true'
},
changeDetection: ChangeDetectionStrategy.OnPush
})
export class HeaderClearStorageComponent {
constructor(private modalSrv: NzModalService, private messageSrv: NzMessageService) {}
@HostListener('click')
_click(): void {
this.modalSrv.confirm({
nzTitle: 'Make sure clear all local storage?',
nzOnOk: () => {
localStorage.clear();
this.messageSrv.success('Clear Finished!');
}
});
}
}

View File

@@ -0,0 +1,32 @@
import { ChangeDetectionStrategy, Component, HostListener } from '@angular/core';
import * as screenfull from 'screenfull';
@Component({
selector: 'header-fullscreen',
template: `
<i nz-icon [nzType]="status ? 'fullscreen-exit' : 'fullscreen'"></i>
{{ (status ? 'menu.fullscreen.exit' : 'menu.fullscreen') | i18n }}
`,
host: {
'[class.d-block]': 'true'
},
changeDetection: ChangeDetectionStrategy.OnPush
})
export class HeaderFullScreenComponent {
status = false;
private get sf(): screenfull.Screenfull {
return screenfull as screenfull.Screenfull;
}
@HostListener('window:resize')
_resize(): void {
this.status = this.sf.isFullscreen;
}
@HostListener('click')
_click(): void {
if (this.sf.isEnabled) {
this.sf.toggle();
}
}
}

View File

@@ -0,0 +1,54 @@
import { DOCUMENT } from '@angular/common';
import { ChangeDetectionStrategy, Component, Inject, Input } from '@angular/core';
import { I18NService } from '@core';
import { ALAIN_I18N_TOKEN, SettingsService } from '@delon/theme';
import { BooleanInput, InputBoolean } from '@delon/util/decorator';
@Component({
selector: 'header-i18n',
template: `
<div *ngIf="showLangText" nz-dropdown [nzDropdownMenu]="langMenu" nzPlacement="bottomRight">
<i nz-icon nzType="global"></i>
{{ 'menu.lang' | i18n }}
<i nz-icon nzType="down"></i>
</div>
<i *ngIf="!showLangText" nz-dropdown [nzDropdownMenu]="langMenu" nzPlacement="bottomRight" nz-icon nzType="global"></i>
<nz-dropdown-menu #langMenu="nzDropdownMenu">
<ul nz-menu>
<li nz-menu-item *ngFor="let item of langs" [nzSelected]="item.code === curLangCode" (click)="change(item.code)">
<span role="img" [attr.aria-label]="item.text" class="pr-xs">{{ item.abbr }}</span>
{{ item.text }}
</li>
</ul>
</nz-dropdown-menu>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class HeaderI18nComponent {
static ngAcceptInputType_showLangText: BooleanInput;
/** Whether to display language text */
@Input() @InputBoolean() showLangText = true;
get langs(): Array<{ code: string; text: string; abbr: string }> {
return this.i18n.getLangs();
}
get curLangCode(): string {
return this.settings.layout.lang;
}
constructor(private settings: SettingsService, @Inject(ALAIN_I18N_TOKEN) private i18n: I18NService, @Inject(DOCUMENT) private doc: any) {}
change(lang: string): void {
const spinEl = this.doc.createElement('div');
spinEl.setAttribute('class', `page-loading ant-spin ant-spin-lg ant-spin-spinning`);
spinEl.innerHTML = `<span class="ant-spin-dot ant-spin-dot-spin"><i></i><i></i><i></i><i></i></span>`;
this.doc.body.appendChild(spinEl);
this.i18n.loadLangData(lang).subscribe(res => {
this.i18n.use(lang, res);
this.settings.setLayout('lang', lang);
setTimeout(() => this.doc.location.reload());
});
}
}

View File

@@ -0,0 +1,108 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
EventEmitter,
HostBinding,
Input,
OnDestroy,
Output
} from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { debounceTime, distinctUntilChanged, tap } from 'rxjs/operators';
@Component({
selector: 'header-search',
template: `
<nz-input-group [nzPrefix]="iconTpl" [nzSuffix]="loadingTpl">
<ng-template #iconTpl>
<i nz-icon [nzType]="focus ? 'arrow-down' : 'search'"></i>
</ng-template>
<ng-template #loadingTpl>
<i *ngIf="loading" nz-icon nzType="loading"></i>
</ng-template>
<input
type="text"
nz-input
[(ngModel)]="q"
[nzAutocomplete]="auto"
(input)="search($event)"
(focus)="qFocus()"
(blur)="qBlur()"
[attr.placeholder]="'menu.search.placeholder' | i18n"
/>
</nz-input-group>
<nz-autocomplete nzBackfill #auto>
<nz-auto-option *ngFor="let i of options" [nzValue]="i">{{ i }}</nz-auto-option>
</nz-autocomplete>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class HeaderSearchComponent implements AfterViewInit, OnDestroy {
q = '';
qIpt: HTMLInputElement | null = null;
options: string[] = [];
search$ = new BehaviorSubject('');
loading = false;
@HostBinding('class.alain-default__search-focus')
focus = false;
@HostBinding('class.alain-default__search-toggled')
searchToggled = false;
@Input()
set toggleChange(value: boolean) {
if (typeof value === 'undefined') {
return;
}
this.searchToggled = value;
this.focus = value;
if (value) {
setTimeout(() => this.qIpt!.focus());
}
}
@Output() readonly toggleChangeChange = new EventEmitter<boolean>();
constructor(private el: ElementRef<HTMLElement>, private cdr: ChangeDetectorRef) {}
ngAfterViewInit(): void {
this.qIpt = this.el.nativeElement.querySelector('.ant-input') as HTMLInputElement;
this.search$
.pipe(
debounceTime(500),
distinctUntilChanged(),
tap({
complete: () => {
this.loading = true;
}
})
)
.subscribe(value => {
this.options = value ? [value, value + value, value + value + value] : [];
this.loading = false;
this.cdr.detectChanges();
});
}
qFocus(): void {
this.focus = true;
}
qBlur(): void {
this.focus = false;
this.searchToggled = false;
this.options.length = 0;
this.toggleChangeChange.emit(false);
}
search(ev: Event): void {
this.search$.next((ev.target as HTMLInputElement).value);
}
ngOnDestroy(): void {
this.search$.complete();
this.search$.unsubscribe();
}
}

View File

@@ -0,0 +1,48 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { Router } from '@angular/router';
import { DA_SERVICE_TOKEN, ITokenService } from '@delon/auth';
import { SettingsService, User } from '@delon/theme';
@Component({
selector: 'header-user',
template: `
<div class="alain-default__nav-item d-flex align-items-center px-sm" nz-dropdown nzPlacement="bottomRight" [nzDropdownMenu]="userMenu">
<nz-avatar [nzSrc]="user.avatar" nzSize="small" class="mr-sm"></nz-avatar>
{{ 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>
{{ 'menu.account.center' | i18n }}
</div>
<div nz-menu-item routerLink="/pro/account/settings">
<i nz-icon nzType="setting" class="mr-sm"></i>
{{ 'menu.account.settings' | i18n }}
</div>
<div nz-menu-item routerLink="/exception/trigger">
<i nz-icon nzType="close-circle" class="mr-sm"></i>
{{ 'menu.account.trigger' | i18n }}
</div>
<li nz-menu-divider></li>
<div nz-menu-item (click)="logout()">
<i nz-icon nzType="logout" class="mr-sm"></i>
{{ 'menu.account.logout' | i18n }}
</div>
</div>
</nz-dropdown-menu>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class HeaderUserComponent {
get user(): User {
return this.settings.user;
}
constructor(private settings: SettingsService, 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://ng-alain.com/theme/blank)

View File

@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
@Component({
selector: 'layout-blank',
template: `<router-outlet></router-outlet> `,
host: {
'[class.alain-blank]': 'true'
}
})
export class LayoutBlankComponent {}

View File

@@ -0,0 +1,70 @@
/* eslint-disable import/order */
import { CommonModule } from '@angular/common';
import { NgModule } 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 { LayoutDefaultModule } from '@delon/theme/layout-default';
import { SettingDrawerModule } from '@delon/theme/setting-drawer';
import { ThemeBtnModule } from '@delon/theme/theme-btn';
import { NzAutocompleteModule } from 'ng-zorro-antd/auto-complete';
import { NzAvatarModule } from 'ng-zorro-antd/avatar';
import { NzBadgeModule } from 'ng-zorro-antd/badge';
import { NzDropDownModule } from 'ng-zorro-antd/dropdown';
import { NzFormModule } from 'ng-zorro-antd/form';
import { NzGridModule } from 'ng-zorro-antd/grid';
import { NzIconModule } from 'ng-zorro-antd/icon';
import { NzInputModule } from 'ng-zorro-antd/input';
import { NzSpinModule } from 'ng-zorro-antd/spin';
import { LayoutBasicComponent } from './basic/basic.component';
import { HeaderClearStorageComponent } from './basic/widgets/clear-storage.component';
import { HeaderFullScreenComponent } from './basic/widgets/fullscreen.component';
import { HeaderI18nComponent } from './basic/widgets/i18n.component';
import { HeaderSearchComponent } from './basic/widgets/search.component';
import { HeaderUserComponent } from './basic/widgets/user.component';
import { LayoutBlankComponent } from './blank/blank.component';
const COMPONENTS = [LayoutBasicComponent, LayoutBlankComponent];
const HEADER_COMPONENTS = [
HeaderSearchComponent,
HeaderFullScreenComponent,
HeaderI18nComponent,
HeaderClearStorageComponent,
HeaderUserComponent,
];
// passport
import { LayoutPassportComponent } from './passport/passport.component';
const PASSPORT = [
LayoutPassportComponent
];
@NgModule({
imports: [
CommonModule,
FormsModule,
RouterModule,
AlainThemeModule.forChild(),
ThemeBtnModule,
SettingDrawerModule,
LayoutDefaultModule,
NoticeIconModule,
GlobalFooterModule,
NzDropDownModule,
NzInputModule,
NzAutocompleteModule,
NzGridModule,
NzFormModule,
NzSpinModule,
NzBadgeModule,
NzAvatarModule,
NzIconModule,
],
declarations: [...COMPONENTS, ...HEADER_COMPONENTS, ...PASSPORT],
exports: [...COMPONENTS, ...PASSPORT],
})
export class LayoutModule { }

View File

@@ -0,0 +1,19 @@
<div class="container">
<header-i18n showLangText="false" class="langs"></header-i18n>
<div class="wrap">
<div class="top">
<div class="head">
<img class="logo" src="./assets/logo-color.svg">
<span class="title">HertzBeat</span>
</div>
<div class="desc">面向开发者,易用友好的高性能监控云服务</div>
</div>
<router-outlet></router-outlet>
<global-footer [links]="links">
Copyright
<i class="anticon anticon-copyright"></i> 2021
<a href="https://tancloud.cn" target="_blank">探云 tancloud.cn | </a>
<a href="https://tancloud.cn" target="_blank">赫兹跳动 hertzbeat.com</a>
</global-footer>
</div>
</div>

View File

@@ -0,0 +1,98 @@
@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;
.anticon {
margin-top: 24px;
margin-right: 24px;
font-size: 14px;
vertical-align: top;
cursor: pointer;
}
}
.wrap {
flex: 1;
padding: 32px 0;
}
.ant-form-item {
display: flex;
justify-content: space-between;
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;
}
}
[data-theme='dark'] {
:host ::ng-deep {
.container {
background: #141414;
}
.title {
color: fade(@white, 85%);
}
.desc {
color: fade(@white, 45%);
}
@media (min-width: @screen-md-min) {
.container {
background-image: none;
}
}
}
}
[data-theme='compact'] {
:host ::ng-deep {
.ant-form-item {
margin-bottom: 16px;
}
}
}

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

@@ -1,13 +0,0 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { WelcomeComponent } from './welcome.component';
const routes: Routes = [
{ path: '', component: WelcomeComponent },
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class WelcomeRoutingModule { }

View File

@@ -1,15 +0,0 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-welcome',
templateUrl: './welcome.component.html',
styleUrls: ['./welcome.component.less']
})
export class WelcomeComponent implements OnInit {
constructor() { }
ngOnInit() {
}
}

View File

@@ -1,13 +0,0 @@
import { NgModule } from '@angular/core';
import { WelcomeRoutingModule } from './welcome-routing.module';
import { WelcomeComponent } from './welcome.component';
@NgModule({
imports: [WelcomeRoutingModule],
declarations: [WelcomeComponent],
exports: [WelcomeComponent]
})
export class WelcomeModule { }

View File

@@ -0,0 +1 @@
<page-header></page-header>

View File

@@ -0,0 +1,8 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'app-dashboard',
templateUrl: './dashboard.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DashboardComponent {}

View File

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

View File

@@ -0,0 +1,16 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { ExceptionType } from '@delon/abc/exception';
@Component({
selector: 'app-exception',
template: ` <exception [type]="type" style="min-height: 500px; height: 80%;"> </exception> `,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ExceptionComponent {
get type(): ExceptionType {
return this.route.snapshot.data.type;
}
constructor(private route: ActivatedRoute) {}
}

View File

@@ -0,0 +1,15 @@
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 { ExceptionRoutingModule } from './exception-routing.module';
import { ExceptionComponent } from './exception.component';
import { ExceptionTriggerComponent } from './trigger.component';
@NgModule({
imports: [CommonModule, DelonExceptionModule, NzButtonModule, NzCardModule, ExceptionRoutingModule],
declarations: [ExceptionComponent, ExceptionTriggerComponent]
})
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]="'validation.password.required' | i18n">
<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">{{ 'app.lock' | i18n }}</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,74 @@
<form nz-form [formGroup]="form" (ngSubmit)="submit()" role="form">
<nz-tabset [nzAnimated]="false" class="tabs" (nzSelectChange)="switch($event)">
<nz-tab [nzTitle]="'app.login.tab-login-credentials' | i18n">
<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">
<nz-input-group nzSize="large" nzPrefixIcon="lock">
<input nz-input type="password" formControlName="password" placeholder="password: admin@123" />
</nz-input-group>
</nz-form-control>
</nz-form-item>
</nz-tab>
<nz-tab [nzTitle]="'app.login.tab-login-mobile' | i18n">
<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">
{{ 'validation.phone-number.required' | i18n }}
</ng-container>
<ng-container *ngIf="i.errors.pattern">
{{ 'validation.phone-number.wrong-format' | i18n }}
</ng-container>
</ng-template>
</nz-form-control>
</nz-form-item>
<nz-form-item>
<nz-form-control [nzErrorTip]="'validation.verification-code.required' | i18n">
<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' : ('app.register.get-verification-code' | i18n) }}
</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">{{ 'app.login.remember-me' | i18n }}</label>
</nz-col>
<nz-col [nzSpan]="12" class="text-right">
<a class="forgot" routerLink="/passport/register">{{ 'app.login.forgot-password' | i18n }}</a>
</nz-col>
</nz-form-item>
<nz-form-item>
<button nz-button type="submit" nzType="primary" nzSize="large" [nzLoading]="loading" nzBlock>
{{ 'app.login.login' | i18n }}
</button>
</nz-form-item>
</form>
<div class="other">
{{ 'app.login.sign-in-with' | i18n }}
<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">{{ 'app.login.signup' | i18n }}</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,196 @@
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, Validators.pattern(/^(admin|user)$/)]],
password: [null, [Validators.required, Validators.pattern(/^(admin@123)$/)]],
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.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().subscribe(() => {
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,13 @@
<result type="success" [title]="title" description="{{ 'app.register-result.activation-email' | i18n }}">
<ng-template #title>
<div class="title" style="font-size: 20px">
{{ 'app.register-result.msg' | i18n: params }}
</div>
</ng-template>
<button (click)="msg.success('email')" nz-button nzSize="large" [nzType]="'primary'">
{{ 'app.register-result.view-mailbox' | i18n }}
</button>
<button routerLink="/" nz-button nzSize="large">
{{ 'app.register-result.back-home' | i18n }}
</button>
</result>

View File

@@ -0,0 +1,15 @@
import { Component } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { NzMessageService } from 'ng-zorro-antd/message';
@Component({
selector: 'passport-register-result',
templateUrl: './register-result.component.html'
})
export class UserRegisterResultComponent {
params = { email: '' };
email = '';
constructor(route: ActivatedRoute, public msg: NzMessageService) {
this.params.email = this.email = route.snapshot.queryParams.email || 'ng-alain@example.com';
}
}

View File

@@ -0,0 +1,100 @@
<h3>{{ 'app.register.register' | i18n }}</h3>
<form nz-form [formGroup]="form" (ngSubmit)="submit()" role="form">
<nz-alert *ngIf="error" [nzType]="'error'" [nzMessage]="error" [nzShowIcon]="true" class="mb-lg"></nz-alert>
<nz-form-item>
<nz-form-control [nzErrorTip]="mailErrorTip">
<nz-input-group nzSize="large" nzAddonBeforeIcon="user">
<input nz-input formControlName="mail" placeholder="Email" />
</nz-input-group>
<ng-template #mailErrorTip let-i>
<ng-container *ngIf="i.errors?.required">{{ 'validation.email.required' | i18n }}</ng-container>
<ng-container *ngIf="i.errors?.email">{{ 'validation.email.wrong-format' | i18n }}</ng-container>
</ng-template>
</nz-form-control>
</nz-form-item>
<nz-form-item>
<nz-form-control [nzErrorTip]="'validation.password.required' | i18n">
<nz-input-group
nzSize="large"
nzAddonBeforeIcon="lock"
nz-popover
nzPopoverPlacement="right"
nzPopoverTrigger="focus"
[(nzPopoverVisible)]="visible"
nzPopoverOverlayClassName="register-password-cdk"
[nzPopoverOverlayStyle]="{ 'width.px': 240 }"
[nzPopoverContent]="pwdCdkTpl"
>
<input nz-input type="password" formControlName="password" placeholder="Password" />
</nz-input-group>
<ng-template #pwdCdkTpl>
<div style="padding: 4px 0">
<ng-container [ngSwitch]="status">
<div *ngSwitchCase="'ok'" class="success">{{ 'validation.password.strength.strong' | i18n }}</div>
<div *ngSwitchCase="'pass'" class="warning">{{ 'validation.password.strength.medium' | i18n }}</div>
<div *ngSwitchDefault class="error">{{ 'validation.password.strength.short' | i18n }}</div>
</ng-container>
<div class="progress-{{ status }}">
<nz-progress
[nzPercent]="progress"
[nzStatus]="passwordProgressMap[status]"
[nzStrokeWidth]="6"
[nzShowInfo]="false"
></nz-progress>
</div>
<p class="mt-sm">{{ 'validation.password.strength.msg' | i18n }}</p>
</div>
</ng-template>
</nz-form-control>
</nz-form-item>
<nz-form-item>
<nz-form-control [nzErrorTip]="confirmErrorTip">
<nz-input-group nzSize="large" nzAddonBeforeIcon="lock">
<input nz-input type="password" formControlName="confirm" placeholder="Confirm Password" />
</nz-input-group>
<ng-template #confirmErrorTip let-i>
<ng-container *ngIf="i.errors?.required">{{ 'validation.confirm-password.required' | i18n }}</ng-container>
<ng-container *ngIf="i.errors?.matchControl">{{ 'validation.password.twice' | i18n }}</ng-container>
</ng-template>
</nz-form-control>
</nz-form-item>
<nz-form-item>
<nz-form-control [nzErrorTip]="mobileErrorTip">
<nz-input-group nzSize="large" [nzAddOnBefore]="addOnBeforeTemplate">
<ng-template #addOnBeforeTemplate>
<nz-select formControlName="mobilePrefix" style="width: 100px">
<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 placeholder="Phone number" />
</nz-input-group>
<ng-template #mobileErrorTip let-i>
<ng-container *ngIf="i.errors?.required">{{ 'validation.phone-number.required' | i18n }}</ng-container>
<ng-container *ngIf="i.errors?.pattern">{{ 'validation.phone-number.wrong-format' | i18n }}</ng-container>
</ng-template>
</nz-form-control>
</nz-form-item>
<nz-form-item>
<nz-form-control [nzErrorTip]="'validation.verification-code.required' | i18n">
<nz-row [nzGutter]="8">
<nz-col [nzSpan]="16">
<nz-input-group nzSize="large" nzAddonBeforeIcon="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' : ('app.register.get-verification-code' | i18n) }}
</button>
</nz-col>
</nz-row>
</nz-form-control>
</nz-form-item>
<nz-form-item>
<button nz-button nzType="primary" nzSize="large" type="submit" [nzLoading]="loading" class="submit">
{{ 'app.register.register' | i18n }}
</button>
<a class="login" routerLink="/passport/login">{{ 'app.register.sign-in' | i18n }}</a>
</nz-form-item>
</form>

View File

@@ -0,0 +1,42 @@
@import '~@delon/theme/index';
:host {
display: block;
width: 368px;
margin: 0 auto;
::ng-deep {
h3 {
margin-bottom: 20px;
font-size: 16px;
}
.submit {
width: 50%;
}
.login {
float: right;
line-height: @btn-height-lg;
}
}
}
::ng-deep {
.register-password-cdk {
.success,
.warning,
.error {
transition: color 0.3s;
}
.success {
color: @success-color;
}
.warning {
color: @warning-color;
}
.error {
color: @error-color;
}
.progress-pass > .progress {
.ant-progress-bg {
background-color: @warning-color;
}
}
}
}

View File

@@ -0,0 +1,139 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy } from '@angular/core';
import { AbstractControl, FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { _HttpClient } from '@delon/theme';
import { MatchControl } from '@delon/util/form';
import { NzSafeAny } from 'ng-zorro-antd/core/types';
import { finalize } from 'rxjs/operators';
@Component({
selector: 'passport-register',
templateUrl: './register.component.html',
styleUrls: ['./register.component.less'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserRegisterComponent implements OnDestroy {
constructor(fb: FormBuilder, private router: Router, private http: _HttpClient, private cdr: ChangeDetectorRef) {
this.form = fb.group(
{
mail: [null, [Validators.required, Validators.email]],
password: [null, [Validators.required, Validators.minLength(6), UserRegisterComponent.checkPassword.bind(this)]],
confirm: [null, [Validators.required, Validators.minLength(6)]],
mobilePrefix: ['+86'],
mobile: [null, [Validators.required, Validators.pattern(/^1\d{10}$/)]],
captcha: [null, [Validators.required]]
},
{
validators: MatchControl('password', 'confirm')
}
);
}
// #region fields
get mail(): AbstractControl {
return this.form.controls.mail;
}
get password(): AbstractControl {
return this.form.controls.password;
}
get confirm(): AbstractControl {
return this.form.controls.confirm;
}
get mobile(): AbstractControl {
return this.form.controls.mobile;
}
get captcha(): AbstractControl {
return this.form.controls.captcha;
}
form: FormGroup;
error = '';
type = 0;
loading = false;
visible = false;
status = 'pool';
progress = 0;
passwordProgressMap: { [key: string]: 'success' | 'normal' | 'exception' } = {
ok: 'success',
pass: 'normal',
pool: 'exception'
};
// #endregion
// #region get captcha
count = 0;
interval$: any;
static checkPassword(control: FormControl): NzSafeAny {
if (!control) {
return null;
}
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self: any = this;
self.visible = !!control.value;
if (control.value && control.value.length > 9) {
self.status = 'ok';
} else if (control.value && control.value.length > 5) {
self.status = 'pass';
} else {
self.status = 'pool';
}
if (self.visible) {
self.progress = control.value.length * 10 > 100 ? 100 : control.value.length * 10;
}
}
getCaptcha(): void {
if (this.mobile.invalid) {
this.mobile.markAsDirty({ onlySelf: true });
this.mobile.updateValueAndValidity({ onlySelf: true });
return;
}
this.count = 59;
this.cdr.detectChanges();
this.interval$ = setInterval(() => {
this.count -= 1;
this.cdr.detectChanges();
if (this.count <= 0) {
clearInterval(this.interval$);
}
}, 1000);
}
// #endregion
submit(): void {
this.error = '';
Object.keys(this.form.controls).forEach(key => {
this.form.controls[key].markAsDirty();
this.form.controls[key].updateValueAndValidity();
});
if (this.form.invalid) {
return;
}
const data = this.form.value;
this.loading = true;
this.cdr.detectChanges();
this.http
.post('/register?_allow_anonymous=true', data)
.pipe(
finalize(() => {
this.loading = false;
this.cdr.detectChanges();
})
)
.subscribe(() => {
this.router.navigate(['passport', 'register-result'], { queryParams: { email: data.mail } });
});
}
ngOnDestroy(): void {
if (this.interval$) {
clearInterval(this.interval$);
}
}
}

View File

@@ -0,0 +1,66 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { SimpleGuard } from '@delon/auth';
import { environment } from '@env/environment';
// layout
import { LayoutBasicComponent } from '../layout/basic/basic.component';
import { LayoutPassportComponent } from '../layout/passport/passport.component';
// dashboard pages
import { DashboardComponent } from './dashboard/dashboard.component';
// single pages
import { CallbackComponent } from './passport/callback.component';
import { UserLockComponent } from './passport/lock/lock.component';
// passport pages
import { UserLoginComponent } from './passport/login/login.component';
import { UserRegisterResultComponent } from './passport/register-result/register-result.component';
import { UserRegisterComponent } from './passport/register/register.component';
const routes: Routes = [
{
path: '',
component: LayoutBasicComponent,
canActivate: [SimpleGuard],
children: [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{ path: 'dashboard', component: DashboardComponent, data: { title: '仪表盘' } },
{ path: 'exception', loadChildren: () => import('./exception/exception.module').then(m => m.ExceptionModule) },
// 业务子模块
// { path: 'widgets', loadChildren: () => import('./widgets/widgets.module').then(m => m.WidgetsModule) },
]
},
// 空白布局
// {
// path: 'blank',
// component: LayoutBlankComponent,
// children: [
// ]
// },
// passport
{
path: 'passport',
component: LayoutPassportComponent,
children: [
{ path: 'login', component: UserLoginComponent, data: { title: '登录' } },
{ path: 'register', component: UserRegisterComponent, data: { title: '注册' } },
{ path: 'register-result', component: UserRegisterResultComponent, data: { title: '注册结果' } },
{ path: 'lock', component: UserLockComponent, data: { title: '锁屏' } },
]
},
// 单页不包裹Layout
{ path: 'passport/callback/:type', component: CallbackComponent },
{ path: '**', redirectTo: 'exception/404' },
];
@NgModule({
imports: [
RouterModule.forRoot(
routes, {
useHash: environment.useHash,
// NOTICE: If you use `reuse-tab` component and turn on keepingScroll you can set to `disabled`
// Pls refer to https://ng-alain.com/components/reuse-tab
scrollPositionRestoration: 'top',
}
)],
exports: [RouterModule],
})
export class RouteRoutingModule { }

View File

@@ -0,0 +1,29 @@
import { NgModule, Type } from '@angular/core';
import { SharedModule } from '@shared';
// dashboard pages
import { DashboardComponent } from './dashboard/dashboard.component';
// single pages
import { CallbackComponent } from './passport/callback.component';
import { UserLockComponent } from './passport/lock/lock.component';
// passport pages
import { UserLoginComponent } from './passport/login/login.component';
import { UserRegisterResultComponent } from './passport/register-result/register-result.component';
import { UserRegisterComponent } from './passport/register/register.component';
import { RouteRoutingModule } from './routes-routing.module';
const COMPONENTS: Array<Type<void>> = [
DashboardComponent,
// passport pages
UserLoginComponent,
UserRegisterComponent,
UserRegisterResultComponent,
// single pages
CallbackComponent,
UserLockComponent,
];
@NgModule({
imports: [SharedModule, RouteRoutingModule],
declarations: COMPONENTS,
})
export class RoutesModule {}

View File

@@ -0,0 +1,2 @@
### 公共通用小组件

View File

@@ -0,0 +1,8 @@
// Components
// Utils
export * from './utils/yuan';
// Module
export * from './shared.module';
export * from './json-schema/json-schema.module';

View File

@@ -0,0 +1,3 @@
# 建议统一在 `widgets` 目录下自定义小部件
> 注:@delon/form 本身提供 nz-zorro-antd 数据录入组件的全部实现,以及若干第三方组件的代码,可从[widgets-third](https://github.com/ng-alain/delon/tree/master/packages/form/widgets-third)中获取并放置 `widgets` 目录下注册即可。

View File

@@ -0,0 +1,18 @@
import { NgModule } from '@angular/core';
import { DelonFormModule, WidgetRegistry } from '@delon/form';
import { SharedModule } from '../shared.module';
import { TestWidget } from './test/test.widget';
export const SCHEMA_THIRDS_COMPONENTS = [TestWidget];
@NgModule({
declarations: SCHEMA_THIRDS_COMPONENTS,
imports: [SharedModule, DelonFormModule.forRoot()],
exports: SCHEMA_THIRDS_COMPONENTS
})
export class JsonSchemaModule {
constructor(widgetRegistry: WidgetRegistry) {
widgetRegistry.register(TestWidget.KEY, TestWidget);
}
}

View File

@@ -0,0 +1,20 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { ControlWidget } from '@delon/form';
@Component({
selector: 'test',
template: `
<sf-item-wrap [id]="id" [schema]="schema" [ui]="ui" [showError]="showError" [error]="error" [showTitle]="schema.title">
test widget
</sf-item-wrap>
`,
preserveWhitespaces: false,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TestWidget extends ControlWidget implements OnInit {
static readonly KEY = 'test';
ngOnInit(): void {
console.warn('init test widget');
}
}

View File

@@ -0,0 +1,7 @@
import { PageHeaderModule } from '@delon/abc/page-header';
import { ResultModule } from '@delon/abc/result';
import { SEModule } from '@delon/abc/se';
import { STModule } from '@delon/abc/st';
import { SVModule } from '@delon/abc/sv';
export const SHARED_DELON_MODULES = [PageHeaderModule, STModule, SEModule, SVModule, ResultModule];

View File

@@ -0,0 +1,45 @@
import { NzAlertModule } from 'ng-zorro-antd/alert';
import { NzAvatarModule } from 'ng-zorro-antd/avatar';
import { NzButtonModule } from 'ng-zorro-antd/button';
import { NzCardModule } from 'ng-zorro-antd/card';
import { NzCheckboxModule } from 'ng-zorro-antd/checkbox';
import { NzDrawerModule } from 'ng-zorro-antd/drawer';
import { NzDropDownModule } from 'ng-zorro-antd/dropdown';
import { NzFormModule } from 'ng-zorro-antd/form';
import { NzGridModule } from 'ng-zorro-antd/grid';
import { NzIconModule } from 'ng-zorro-antd/icon';
import { NzInputModule } from 'ng-zorro-antd/input';
import { NzInputNumberModule } from 'ng-zorro-antd/input-number';
import { NzModalModule } from 'ng-zorro-antd/modal';
import { NzPopconfirmModule } from 'ng-zorro-antd/popconfirm';
import { NzPopoverModule } from 'ng-zorro-antd/popover';
import { NzProgressModule } from 'ng-zorro-antd/progress';
import { NzSelectModule } from 'ng-zorro-antd/select';
import { NzSpinModule } from 'ng-zorro-antd/spin';
import { NzTableModule } from 'ng-zorro-antd/table';
import { NzTabsModule } from 'ng-zorro-antd/tabs';
import { NzToolTipModule } from 'ng-zorro-antd/tooltip';
export const SHARED_ZORRO_MODULES = [
NzFormModule,
NzGridModule,
NzButtonModule,
NzInputModule,
NzInputNumberModule,
NzAlertModule,
NzProgressModule,
NzSelectModule,
NzAvatarModule,
NzCardModule,
NzDropDownModule,
NzPopconfirmModule,
NzTableModule,
NzPopoverModule,
NzDrawerModule,
NzModalModule,
NzTabsModule,
NzToolTipModule,
NzIconModule,
NzCheckboxModule,
NzSpinModule,
];

View File

@@ -0,0 +1,61 @@
import { NgModule, Type } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { AlainThemeModule } from '@delon/theme';
import { DelonACLModule } from '@delon/acl';
import { DelonFormModule } from '@delon/form';
import { SHARED_DELON_MODULES } from './shared-delon.module';
import { SHARED_ZORRO_MODULES } from './shared-zorro.module';
// #region third libs
const THIRDMODULES: Array<Type<void>> = [];
// #endregion
// #region your components & directives
const COMPONENTS: Array<Type<void>> = [];
const DIRECTIVES: Array<Type<void>> = [];
// #endregion
@NgModule({
imports: [
CommonModule,
FormsModule,
RouterModule,
ReactiveFormsModule,
AlainThemeModule.forChild(),
DelonACLModule,
DelonFormModule,
...SHARED_DELON_MODULES,
...SHARED_ZORRO_MODULES,
// third libs
...THIRDMODULES
],
declarations: [
// your components
...COMPONENTS,
...DIRECTIVES
],
exports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
RouterModule,
AlainThemeModule,
DelonACLModule,
DelonFormModule,
...SHARED_DELON_MODULES,
...SHARED_ZORRO_MODULES,
// third libs
...THIRDMODULES,
// your components
...COMPONENTS,
...DIRECTIVES
]
})
export class SharedModule { }

View File

@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
// import { STWidgetRegistry } from '@delon/abc/st';
import { SharedModule } from '../shared.module';
export const STWIDGET_COMPONENTS = [];
@NgModule({
declarations: STWIDGET_COMPONENTS,
imports: [SharedModule],
exports: [...STWIDGET_COMPONENTS]
})
export class STWidgetModule {
// constructor(widgetRegistry: STWidgetRegistry) {
// widgetRegistry.register(STImgWidget.KEY, STImgWidget);
// }
}

View File

@@ -0,0 +1,11 @@
/**
* 转化成RMB元字符串
*
* @param digits 当数字类型时允许指定小数点后数字的个数默认2位小数
*/
export function yuan(value: number | string, digits: number = 2): string {
if (typeof value === 'number') {
value = value.toFixed(digits);
}
return `&yen ${value}`;
}