项目初始化

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

View File

@ -0,0 +1,16 @@
<nz-cascader
*ngIf="data"
[(ngModel)]="value"
(ngModelChange)="change()"
[nzOptions]="data"
[nzDisabled]="disabled"
[nzAllowClear]="allowClear"
[nzAutoFocus]="autoFocus"
[nzNotFoundContent]="notFoundContent"
[nzSize]="size"
[nzShowSearch]="showSearch"
[nzPlaceHolder]="placeHolder"
[nzMouseEnterDelay]="mouseEnterDelay"
[nzMouseLeaveDelay]="mouseLeaveDelay"
[nzTriggerAction]="triggerAction"
></nz-cascader>

View File

@ -0,0 +1,84 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, Input, OnInit } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { BooleanInput, InputBoolean } from '@delon/util';
import {
NzCascaderExpandTrigger,
NzCascaderOption,
NzCascaderSize,
NzCascaderTriggerType,
NzShowSearchOptions
} from 'ng-zorro-antd/cascader';
import { AddressService, AddressType } from './address.service';
@Component({
selector: 'address',
templateUrl: './address.component.html',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => AddressComponent),
multi: true
}
],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AddressComponent implements OnInit, ControlValueAccessor {
static ngAcceptInputType_allowClear: BooleanInput;
static ngAcceptInputType_autoFocus: BooleanInput;
static ngAcceptInputType_disabled: BooleanInput;
private onChangeFn?: (val: string) => void;
private onTouchedFn?: () => void;
value: string[] = [];
data?: NzCascaderOption[];
// #region fields
@Input() type: AddressType = 'pca';
// Original attributes
@Input() @InputBoolean() allowClear = true;
@Input() @InputBoolean() autoFocus = false;
@Input() @InputBoolean() disabled = false;
@Input() expandTrigger: NzCascaderExpandTrigger = 'click';
@Input() notFoundContent?: string;
@Input() size: NzCascaderSize = 'default';
@Input() showSearch!: boolean | NzShowSearchOptions;
@Input() placeHolder = '请选择所在地';
@Input() mouseEnterDelay = 150; // ms
@Input() mouseLeaveDelay = 150; // ms
@Input() triggerAction: NzCascaderTriggerType | NzCascaderTriggerType[] = ['click'] as NzCascaderTriggerType[];
// #endregion
constructor(private srv: AddressService, private cdr: ChangeDetectorRef) {}
change(): void {
this.onChangeFn!(this.value.pop()!);
}
ngOnInit(): void {
this.srv[this.type].subscribe(res => {
this.data = res;
this.cdr.markForCheck();
});
}
writeValue(geo: string): void {
if (geo == null) {
this.value = [];
return;
}
this.value = this.srv.toValueArr(geo, this.type);
}
registerOnChange(fn: any): void {
this.onChangeFn = fn;
}
registerOnTouched(fn: any): void {
this.onTouchedFn = fn;
}
setDisabledState?(isDisabled: boolean): void {
this.disabled = isDisabled;
}
}

View File

@ -0,0 +1,15 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NzCascaderModule } from 'ng-zorro-antd/cascader';
import { AddressComponent } from './address.component';
const COMPONENTS = [AddressComponent];
@NgModule({
imports: [CommonModule, FormsModule, NzCascaderModule],
declarations: COMPONENTS,
exports: COMPONENTS
})
export class AddressModule {}

View File

@ -0,0 +1,79 @@
import { Injectable } from '@angular/core';
import { _HttpClient } from '@delon/theme';
import { ArrayService } from '@delon/util';
import { NzCascaderOption } from 'ng-zorro-antd/cascader';
import { Observable, of } from 'rxjs';
import { map } from 'rxjs/operators';
export interface PCCode {
code?: string;
name?: string;
value?: string;
label?: string;
isLeaf?: boolean;
children?: PCCode[];
}
export type AddressType = 'pc' | 'pca';
const MAXLENGTH = 6;
@Injectable({ providedIn: 'root' })
export class AddressService {
private _pcCode?: NzCascaderOption[];
private _pcaCode?: NzCascaderOption[];
/**
* “省份、城市” 二级联动数据,数据来源于 [pc-code.json](https://github.com/modood/Administrative-divisions-of-China/blob/master/dist/pc-code.json)
*/
get pc(): Observable<NzCascaderOption[]> {
return this._pcCode ? of(this._pcCode) : this.getPcCode();
}
/**
* “省份、城市、区县” 三级联动数据,数据来源于 [pc-code.json](https://github.com/modood/Administrative-divisions-of-China/blob/master/dist/pca-code.json)
*/
get pca(): Observable<NzCascaderOption[]> {
return this._pcaCode ? of(this._pcaCode) : this.getPcaCode();
}
constructor(private http: _HttpClient, private arrSrv: ArrayService) {}
/**
* 始终保持 6 位数,不足补 `0`
*/
fixValue(val: string): string {
return `${val}000000`.substr(0, MAXLENGTH);
}
toValueArr(val: string, type: AddressType): string[] {
val = this.fixValue(val);
const res: string[] = [];
if (type === 'pc') {
res.push(val.substr(0, 2), val);
} else {
for (let i = 0; i < MAXLENGTH; i += 2) {
res.push(val.substr(0, i + 2));
}
}
return res.map(this.fixValue);
}
private map = (res: PCCode[]): NzCascaderOption[] => {
this.arrSrv.visitTree(res, (item: PCCode) => {
item.value = this.fixValue(item.code!);
item.label = item.name;
if (!item.children) {
item.isLeaf = true;
}
});
return res;
};
private getPcCode(): Observable<NzCascaderOption[]> {
return this.http.get('./assets/tmp/pc-code.json').pipe(map(this.map));
}
private getPcaCode(): Observable<NzCascaderOption[]> {
return this.http.get('./assets/tmp/pca-code.json').pipe(map(this.map));
}
}

View File

@ -0,0 +1,26 @@
---
order: 110
title: address
type: Component
---
China address picker, support two styles of “province, city” or “province, city, district”, refer to the account management example.
## API
| Property | Description | Type | Default |
|----------|----|----------|--------|
| `[ngModel]` | selected value return the city or district code | `string` | - |
| `[type]` | Type of address | `pc,pca` | `pca` |
| `[allowClear]` | whether allow clear | `boolean` | `true` |
| `[autoFocus]` | whether auto focus the input box | `boolean` | `false` |
| `[disabled]` | whether disabled select | `boolean` | `false` |
| `[expandTrigger]` | expand current item when click or hover, one of 'click' 'hover' | `'click''hover'` | `'click'` |
| `[notFoundContent]` | Specify content to show when no result matches. | `string` | - |
| `[placeHolder]` | input placeholder | `string` | `'请选择所在地'` |
| `[showSearch]` | Whether support search. Cannot be used with `[nzLoadData]` at the same time | `booleanNzShowSearchOptions` | `false` |
| `[size]` | input size, one of `large` `default` `small` | `'large''small''default'` | `'default'` |
## sf widget
Widget name: `address`.

View File

@ -0,0 +1,3 @@
export * from './address.service';
export * from './address.component';
export * from './address.module';

View File

@ -0,0 +1,26 @@
---
order: 110
title: address
type: Component
---
地址选择器,支持“省份、城市”或“省份、城市、区县”两种风格,参考账号管理示例。
## API
| 参数 | 说明 | 类型 | 默认值 |
|----------|----|----------|--------|
| `[ngModel]` | 指定选中项,返回城市或区县代码 | `string` | - |
| `[type]` | 类型 | `pc,pca` | `pca` |
| `[allowClear]` | 是否支持清除 | `boolean` | `true` |
| `[autoFocus]` | 是否自动聚焦,当存在输入框时 | `boolean` | `false` |
| `[disabled]` | 禁用 | `boolean` | `false` |
| `[expandTrigger]` | 次级菜单的展开方式,可选 'click' 和 'hover' | `'click''hover'` | `'click'` |
| `[notFoundContent]` | 当下拉列表为空时显示的内容 | `string` | - |
| `[placeHolder]` | 输入框占位文本 | `string` | `'请选择所在地'` |
| `[showSearch]` | 是否支持搜索,默认情况下对 `label` 进行全匹配搜索,不能和 `[nzLoadData]` 同时使用 | `booleanNzShowSearchOptions` | `false` |
| `[size]` | 输入框大小,可选 `large` `default` `small` | `'large''small''default'` | `'default'` |
## sf 小部件
小部件名称:`address`

View File

@ -0,0 +1 @@
<div id="captcha"></div>

View File

@ -0,0 +1,6 @@
:host {
::ng-deep {
.captcha-box {
}
}
}

View File

@ -0,0 +1,79 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { keysConf } from '@conf/keys.conf';
import { Subject } from 'rxjs';
import { EACaptchaService } from '../../services';
import { initNECaptchaWithFallback } from './dun';
@Component({
selector: 'app-captcha',
templateUrl: './captcha.component.html',
styleUrls: ['./captcha.component.less'],
})
export class CaptchaComponent implements OnInit {
@Input() phone!: string; // 手机号
@Input() url!: string; // api地址
@Output() done = new EventEmitter<any>();
captchaIns: any;
initSubject = new Subject<any>();
constructor(public captchaService: EACaptchaService) {}
ngOnInit() {}
init() {
const _this = this;
if (this.captchaIns) {
return this.initSubject;
}
initNECaptchaWithFallback(
{
element: '#captcha',
captchaId: keysConf.yidun_capcha_id,
mode: 'popup',
width: '320px',
onClose: () => {
// 弹出关闭结束后将会触发该函数
},
onVerify: (err: any, data: any) => {
// console.log('🚀 ~ init ~ data', data);
if (data?.validate) {
// 验证通过,获取验证码
_this.captchaDone(data?.validate);
}
},
},
(instance: any) => {
// console.log('🚀 ~ initCaptcha ~ instance', instance);
// 初始化成功后得到验证实例instance可以调用实例的方法
_this.captchaIns = instance;
this.initSubject.next(_this.captchaIns);
},
(err: any) => {
// 初始化失败后触发该函数err对象描述当前错误信息
},
);
return this.initSubject;
}
/* 网易盾验证通过 */
captchaDone(validate: any) {
this.captchaService.getCaptchaByDun(this.phone, validate, this.url || undefined).subscribe((res: any) => {
// console.log('🚀 ~ 验证通过发送验证码=>', res);
if (res) {
this.captchaService.msgSrv.success('验证码发送成功!');
this.done.emit(null);
} else {
this.captchaService.msgSrv.warning(res.msg);
}
});
}
popUp() {
console.log(222222222222222222222222222222222222222);
if (!this.captchaIns) {
this.init();
}
this.captchaIns.refresh();
this.captchaIns.popUp();
}
}

View File

@ -0,0 +1,84 @@
import { Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
import { ComponentRef, Injectable } from '@angular/core';
import { Observable, Subject } from 'rxjs';
import { CaptchaComponent } from './captcha.component';
@Injectable({
providedIn: 'root',
})
export class DunHelper {
captchacontainerRef!: ComponentRef<CaptchaComponent>;
userInfo;
constructor(private overlay: Overlay) {
this.userInfo = JSON.parse(localStorage.getItem('user') || '');
}
/**
* 组件初始化
* @param phone 手机号
* @param url 发送验证码请求地址
*/
init(phone: string, url?: string): Subject<any> {
const overlayRef = this.createOverlay();
const containerPortal = new ComponentPortal(CaptchaComponent);
this.captchacontainerRef = overlayRef.attach<CaptchaComponent>(containerPortal);
this.captchacontainerRef.instance.phone = phone;
this.captchacontainerRef.instance.url = url || '';
return this.captchacontainerRef.instance.init();
}
/**
* 弹出滑块验证
* @param phone 手机号
* @param url 发送验证码请求地址
*/
popUp(phone?: string, url?: string): Observable<any> {
if (this.captchacontainerRef) {
this.destory();
}
this.init(phone || this.userInfo?.phone, url).subscribe((instance) => {
if (instance) {
this.captchacontainerRef.instance.popUp();
}
});
/* if (!this.captchacontainerRef) {
this.init(phone || this.userInfo?.phone, url).subscribe(instance => {
if (instance) {
this.captchacontainerRef.instance.popUp();
}
});
} else {
if (!!phone && !!url) {
this.init(phone || this.userInfo?.phone, url).subscribe(instance => {
if (instance) {
this.captchacontainerRef.instance.popUp();
}
});
} else {
this.captchacontainerRef.instance.popUp();
}
} */
return this.captchacontainerRef.instance.done;
}
/** 组件销毁 */
destory() {
this.captchacontainerRef.destroy();
}
private createOverlay(): OverlayRef {
const overlayConfig = new OverlayConfig({
hasBackdrop: false,
scrollStrategy: this.overlay.scrollStrategies.block(),
positionStrategy: this.overlay.position().global(),
backdropClass: 'captcha-back-drop',
panelClass: 'captcha-overlay',
});
return this.overlay.create(overlayConfig);
}
}

View File

@ -0,0 +1,353 @@
/* eslint-disable no-undef */
let errorCallbackCount: any = 0;
// 常量
const DEFAULT_VALIDATE =
'QjGAuvoHrcpuxlbw7cp4WnIbbjzG4rtSlpc7EDovNHQS._ujzPZpeCInSxIT4WunuDDh8dRZYF2GbBGWyHlC6q5uEi9x-TXT9j7J705vSsBXyTar7aqFYyUltKYJ7f4Y2TXm_1Mn6HFkb4M7URQ_rWtpxQ5D6hCgNJYC0HpRE7.2sttqYKLoi7yP1KHzK-PptdHHkVwb77cwS2EJW7Mj_PsOtnPBubTmTZLpnRECJR99dWTVC11xYG0sx8dJNLUxUFxEyzTfX4nSmQz_T5sXATRKHtVAz7nmV0De5unmflfAlUwMGKlCT1khBtewlgN5nHvyxeD8Z1_fPVzi9oznl-sbegj6lKfCWezmLcwft8.4yaVh6SlzXJq-FnSK.euq9OBd5jYc82ge2_hEca1fGU--SkPRzgwkzew4O4qjdS2utdPwFONnhKAIMJRPUmCV4lPHG1OeRDvyNV8sCnuFMw7leasxIhPoycl4pm5bNy70Z1laozEGJgItVNr3'; // 默认validate
const FALLBACK_LANG: any = {
'zh-CN': '前方拥堵,已自动跳过验证',
en: 'captcha errorVerified automatically',
};
const CACHE_MIN = 1000 * 60; // 缓存时长单位1分钟
const REQUEST_SCRIPT_ERROR = 502;
const RESOURCE_CACHE: any = {};
// 工具函数
function loadScript(src: any, cb: any) {
const head: any = document.head || document.getElementsByTagName('head')[0];
const script: any = document.createElement('script');
cb = cb || function () {};
script.type = 'text/javascript';
script.charset = 'utf8';
script.async = true;
script.src = src;
if (!('onload' in script)) {
script.onreadystatechange = function () {
if (this.readyState !== 'complete' && this.readyState !== 'loaded') {
return;
}
this.onreadystatechange = null;
cb(null, script); // there is no way to catch loading errors in IE8
};
}
script.onload = function () {
this.onerror = this.onload = null;
cb(null, script);
};
script.onerror = function () {
// because even IE9 works not like others
this.onerror = this.onload = null;
cb(new Error('Failed to load ' + this.src), script);
};
head.appendChild(script);
}
function joinUrl(protocol: any, host: any, path: any) {
protocol = protocol || '';
host = host || '';
path = path || '';
if (protocol) {
protocol = protocol.replace(/:?\/{0,2}$/, '://');
}
if (host) {
const matched = host.match(/^([-0-9a-zA-Z.:]*)(\/.*)?/);
host = matched[1];
path = (matched[2] || '') + '/' + path;
}
!host && (protocol = '');
return protocol + host + path;
}
function setDomText(el: any, value: any) {
if (value === undefined) {
return;
}
const nodeType = el.nodeType;
if (nodeType === 1 || nodeType === 11 || nodeType === 9) {
if (typeof el.textContent === 'string') {
el.textContent = value;
} else {
el.innerText = value;
}
}
}
function queryAllByClassName(selector: any, node: any) {
node = node || document;
if (node.querySelectorAll) {
return node.querySelectorAll(selector);
}
if (!/^\.[^.]+$/.test(selector)) {
return [];
}
if (node.getElementsByClassName) {
return node.getElementsByClassName(selector);
}
const children = node.getElementsByTagName('*');
let current;
const result = [];
const className = selector.slice(1);
for (let i = 0, l = children.length; i < l; i++) {
current = children[i];
if (~(' ' + current.className + ' ').indexOf(' ' + className + ' ')) {
result.push(current);
}
}
return result;
}
function assert(condition: any, msg: any) {
if (!condition) {
throw new Error('[NECaptcha] ' + msg);
}
}
function isInteger(val: any) {
if (Number.isInteger) {
return Number.isInteger(val);
}
return typeof val === 'number' && isFinite(val) && Math.floor(val) === val;
}
function isArray(val: any) {
if (Array.isArray) {
return Array.isArray(val);
}
return Object.prototype.toString.call(val) === '[object Array]';
}
function ObjectAssign(a: any, b: any, c: any) {
if (Object.assign) {
// return Object.assign.apply(null, arguments);
return Object.assign.apply(null, arguments as any);
}
const target: any = {};
for (let index = 1; index < arguments.length; index++) {
const source = arguments[index];
if (source != null) {
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
target[key] = source[key];
}
}
}
}
return target;
}
function getTimestamp(msec: any) {
msec = !msec && msec !== 0 ? msec : 1;
return parseInt((new Date().valueOf() / msec).toString(), 10);
}
// 降级方案
function normalizeFallbackConfig(customConfig: any) {
const siteProtocol = window.location.protocol.replace(':', '');
const defaultConf: any = {
protocol: siteProtocol === 'http' ? 'http' : 'https',
lang: 'zh-CN',
errorFallbackCount: 3,
};
const config: any = ObjectAssign({}, defaultConf, customConfig);
const errorFallbackCount: any = config.errorFallbackCount;
assert(
errorFallbackCount === undefined || (isInteger(errorFallbackCount) && errorFallbackCount >= 1),
"errorFallbackCount must be an integer, and it's value greater than or equal one",
);
return config;
}
function loadResource(config: any, cb: any) {
if ((window as any).initNECaptcha) {
return cb(null);
}
function genUrl(server: any) {
const path = 'load.min.js';
let _urls = [];
if (isArray(server)) {
for (let i = 0, len = server.length; i < len; i++) {
_urls.push(joinUrl(config.protocol, server[i], path));
}
} else {
const url = joinUrl(config.protocol, server, path);
_urls = [url, url];
}
return _urls;
}
const urls = genUrl(config.staticServer || ['cstaticdun.126.net', 'cstaticdun1.126.net', 'cstatic.dun.163yun.com']);
function step(i: any) {
const url = urls[i] + '?v=' + getTimestamp(CACHE_MIN);
loadScript(url, function (err: any) {
if (err || !(window as any).initNECaptcha) {
// loadjs的全局变量
i = i + 1;
if (i === urls.length) {
return cb(new Error('Failed to load script(' + url + ').' + (err ? err.message : 'unreliable script')));
}
return step(i);
}
return cb(null);
});
}
step(0);
}
/*
* entry: initNECaptchaWithFallback
* options:
* errorFallbackCount: 触发降级的错误次数,默认第三次错误降级
* defaultFallback: 是否开启默认降级
* onFallback: 自定义降级方案参数为默认validate
*/
export function initNECaptchaWithFallback(options: any, onload: any, onerror: any) {
let captchaIns: any = null;
const config = normalizeFallbackConfig(options);
const defaultFallback = config.defaultFallback !== false;
const langPkg = FALLBACK_LANG[config.lang === 'zh-CN' ? config.lang : 'en'];
const storeKey = window.location.pathname + '_' + config.captchaId + '_NECAPTCHA_ERROR_COUNTS';
try {
errorCallbackCount = parseInt(localStorage.getItem(storeKey)?.toString() || '0', 10);
} catch (error) {}
const fallbackFn = !defaultFallback
? config.onFallback || function () {}
: (validate: any) => {
function setFallbackTip(instance: any) {
if (!instance) {
return;
}
setFallbackTip(instance._captchaIns);
if (!instance.$el) {
return;
}
const tipEles = queryAllByClassName('.yidun-fallback__tip', instance.$el);
if (!tipEles.length) {
return;
}
// 确保在队列的最后
setTimeout(() => {
for (let i = 0, l = tipEles.length; i < l; i++) {
setDomText(tipEles[i], langPkg);
}
}, 0);
}
setFallbackTip(captchaIns);
config.onVerify && config.onVerify(null, { validate: validate });
};
const noFallback = !defaultFallback && !config.onFallback;
const proxyOnError = (error: any) => {
errorCallbackCount++;
if (errorCallbackCount < config.errorFallbackCount) {
try {
localStorage.setItem(storeKey, errorCallbackCount);
} catch (err) {}
onerror(error);
} else {
fallbackFn(DEFAULT_VALIDATE);
proxyRefresh();
noFallback && onerror(error);
}
};
const proxyRefresh = () => {
errorCallbackCount = 0;
try {
localStorage.setItem(storeKey, '0');
} catch (err) {}
};
const triggerInitError = (error: any) => {
if (initialTimer && initialTimer.isError()) {
initialTimer.resetError();
return;
}
initialTimer && initialTimer.resetTimer();
noFallback ? onerror(error) : proxyOnError(error);
};
config.onError = (error: any) => {
if (initialTimer && initialTimer.isError()) {
initialTimer.resetError();
}
proxyOnError(error);
};
config.onDidRefresh = () => {
if (initialTimer && initialTimer.isError()) {
initialTimer.resetError();
}
proxyRefresh();
};
const initialTimer = options.initTimeoutError ? options.initTimeoutError(proxyOnError) : null; // initialTimer is only for mobile.html
const loadResolve = () => {
(window as any).initNECaptcha(
config,
(instance: any) => {
if (initialTimer && initialTimer.isError()) {
return;
}
initialTimer && initialTimer.resetTimer();
captchaIns = instance;
onload && onload(instance);
},
triggerInitError,
);
};
const cacheId = 'load-queue';
if (!RESOURCE_CACHE[cacheId]) {
RESOURCE_CACHE[cacheId] = {
rejects: [],
resolves: [],
status: 'error',
};
}
if (RESOURCE_CACHE[cacheId].status === 'error') {
RESOURCE_CACHE[cacheId].status = 'pending';
loadResource(config, (error: any) => {
if (error) {
const err: any = new Error();
err.code = REQUEST_SCRIPT_ERROR;
err.message = config.staticServer + '/load.min.js error';
const rejects = RESOURCE_CACHE[cacheId].rejects;
for (let i = 0, iLen = rejects.length; i < iLen; i++) {
rejects.pop()(err);
}
RESOURCE_CACHE[cacheId].status = 'error';
} else {
RESOURCE_CACHE[cacheId].status = 'done';
const resolves = RESOURCE_CACHE[cacheId].resolves;
for (let j = 0, jLen = resolves.length; j < jLen; j++) {
resolves.pop()();
}
}
});
} else if (RESOURCE_CACHE[cacheId].status === 'done') {
loadResolve();
}
if (RESOURCE_CACHE[cacheId].status === 'pending') {
RESOURCE_CACHE[cacheId].rejects.push(function loadReject(err: any) {
triggerInitError(err);
});
RESOURCE_CACHE[cacheId].resolves.push(loadResolve);
}
}

View File

@ -0,0 +1,2 @@
export * from './captcha.component';
export * from './dun.helper';

View File

@ -0,0 +1,45 @@
import { AfterViewInit, Directive, EventEmitter, Input, OnDestroy, Optional, Output } from '@angular/core';
import { NgModel } from '@angular/forms';
import { BooleanInput, InputBoolean, InputNumber, NumberInput } from '@delon/util';
import { Subscription } from 'rxjs';
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
@Directive({
selector: '[delay]:not([noDelay])',
exportAs: 'delayComp'
})
export class DelayDirective implements AfterViewInit, OnDestroy {
static ngAcceptInputType_delayTime: NumberInput;
static ngAcceptInputType_delayFirstEmit: BooleanInput;
private data$: Subscription | undefined;
private firstEmit = false;
@Input() @InputNumber() delayTime = 500;
@Input() @InputBoolean() delayFirstEmit = false;
@Output() readonly delayChange = new EventEmitter<any>();
constructor(@Optional() private ngModel: NgModel) {}
ngAfterViewInit(): void {
const { ngModel, delayFirstEmit, delayTime, delayChange } = this;
if (ngModel == null) {
return;
}
this.firstEmit = delayFirstEmit;
this.data$ = ngModel.valueChanges?.pipe(debounceTime(delayTime), distinctUntilChanged()).subscribe(res => {
if (this.firstEmit === false) {
this.firstEmit = true;
return;
}
delayChange.emit(res);
});
}
ngOnDestroy(): void {
if (this.data$) {
this.data$.unsubscribe();
}
}
}

View File

@ -0,0 +1,11 @@
import { NgModule } from '@angular/core';
import { DelayDirective } from './delay.directive';
const COMPONENTS = [DelayDirective];
@NgModule({
declarations: COMPONENTS,
exports: COMPONENTS
})
export class DelayModule {}

View File

@ -0,0 +1,19 @@
---
order: 30
title: delay
type: Component
---
Delay trigger, for title search, [DEMO](https://preview.ng-alain.com/pro/#/other/article).
```html
<input nz-input [(ngModel)]="q" delay (delayChange)="load()" />
```
## API
| Property | Description | Type | Default |
|--------------------|----------------------------------------|---------------------|---------|
| `[delayTime]` | Delay time (unit: ms) | `number` | `500` |
| `[delayFirstEmit]` | Whether to trigger after `delayChange` | `boolean` | `false` |
| `(delayChange)` | Callback event | `EventEmitter<any>` | - |

View File

@ -0,0 +1,2 @@
export * from './delay.directive';
export * from './delay.module';

View File

@ -0,0 +1,19 @@
---
order: 30
title: delay
type: Component
---
延迟触发,适用于标题搜索,参考[示例](https://preview.ng-alain.com/pro/#/other/article)。
```html
<input nz-input [(ngModel)]="q" delay (delayChange)="load()" />
```
## API
| 参数 | 说明 | 类型 | 默认值 |
|--------------------|----------------------------|---------------------|---------|
| `[delayTime]` | 延迟时间(单位:毫秒) | `number` | `500` |
| `[delayFirstEmit]` | 是否加载后触发 `delayChange` | `boolean` | `false` |
| `(delayChange)` | 回调函数 | `EventEmitter<any>` | - |

View File

@ -0,0 +1,417 @@
/**
* Part of the code comes from https://github.com/KillerCodeMonkey/ngx-quill/
*/
import { DOCUMENT, isPlatformServer } from '@angular/common';
import {
AfterViewInit,
Component,
ElementRef,
EventEmitter,
forwardRef,
Inject,
Input,
NgZone,
OnChanges,
OnDestroy,
Output,
PLATFORM_ID,
Renderer2,
SimpleChanges
} from '@angular/core';
import { ControlValueAccessor, NG_VALIDATORS, NG_VALUE_ACCESSOR, Validator } from '@angular/forms';
import { ModalHelper } from '@delon/theme';
import { BooleanInput, InputBoolean, InputNumber, NumberInput } from '@delon/util';
import ImageResize from 'quill-image-resize-module';
import { FileManagerImgComponent } from '../file-manager/file-manager-img.component';
declare var Quill: any;
const Delta = require('quill-delta');
export interface CustomOption {
import: string;
whitelist: any[];
}
Quill.register('modules/imageResize', ImageResize);
['align', 'background', 'color', 'direction', 'font'].forEach(type => Quill.register(Quill.import(`attributors/style/${type}`), true));
const Size = Quill.import('attributors/style/size');
const VALUES = {
// NOTICE: Should be sync modify `@ql-sizes` in `styles/fix/_quill.less`
size: ['10px', '12px', '14px', '16px', '18px', '20px', '24px']
};
Size.whitelist = VALUES.size;
Quill.register(Size, true);
@Component({
selector: 'editor',
template: ``,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => EditorComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => EditorComponent),
multi: true
}
],
host: {
'[class.quill-editor]': 'true'
},
preserveWhitespaces: false
})
export class EditorComponent implements AfterViewInit, ControlValueAccessor, OnChanges, OnDestroy, Validator {
@Input()
set mode(value: 'full' | 'simple') {
this._mode = value;
const handlers = {
image: (state: boolean) => this.image(state)
};
if (value === 'full') {
this.modules = {
imageResize: {},
toolbar: {
handlers,
container: [
['bold', 'italic', 'underline', 'strike'], // toggled buttons
['blockquote', 'code-block'],
[{ header: 1 }, { header: 2 }], // custom button values
[{ list: 'ordered' }, { list: 'bullet' }],
[{ script: 'sub' }, { script: 'super' }], // superscript/subscript
[{ indent: '-1' }, { indent: '+1' }], // outdent/indent
// [{ direction: 'rtl' }], // text direction
[{ size: VALUES.size }], // custom dropdown
[{ header: [1, 2, 3, false] }],
[{ color: [] }, { background: [] }], // dropdown with defaults from theme
// [{ font: [] }],
[{ align: [] }],
['clean'], // remove formatting button
['link', 'image', 'video'] // link and image, video
]
}
};
} else {
this.modules = {
imageResize: {},
toolbar: {
handlers,
container: [
['bold', 'italic', 'underline', 'strike', 'blockquote'], // toggled buttons
[{ header: 1 }, { header: 2 }], // custom button values
[{ list: 'ordered' }, { list: 'bullet' }],
[{ header: [1, 2, 3, false] }],
[{ color: [] }, { background: [] }], // dropdown with defaults from theme
[{ align: [] }],
['clean'], // remove formatting button
['link', 'image', 'video'] // link and image, video
]
}
};
}
}
constructor(
private elementRef: ElementRef,
@Inject(DOCUMENT) private doc: any,
@Inject(PLATFORM_ID) private platformId: {},
private renderer: Renderer2,
private zone: NgZone,
private modalHelper: ModalHelper
) {}
static ngAcceptInputType_readOnly: BooleanInput;
static ngAcceptInputType_maxLength: NumberInput;
static ngAcceptInputType_minLength: NumberInput;
static ngAcceptInputType_required: BooleanInput;
static ngAcceptInputType_strict: BooleanInput;
quill: any;
editorElem!: HTMLElement;
emptyArray: any[] = [];
content: any;
selectionChangeEvent: any;
textChangeEvent: any;
_mode!: 'full' | 'simple';
@Input() format: 'object' | 'html' | 'text' | 'json' = 'html';
@Input() theme?: string;
@Input() modules?: { [index: string]: any };
@Input() @InputBoolean() readOnly?: boolean;
@Input() placeholder = '';
@Input() @InputNumber() maxLength?: number;
@Input() @InputNumber() minLength?: number;
@Input() @InputBoolean() required?: boolean;
@Input() formats?: string[];
@Input() style: any = { height: '250px' };
@Input() @InputBoolean() strict = true;
@Input() scrollingContainer?: HTMLElement | string;
@Input() bounds?: HTMLElement | string;
@Input() customOptions: CustomOption[] = [];
@Output() readonly editorCreated = new EventEmitter();
@Output() readonly contentChanged = new EventEmitter();
@Output() readonly selectionChanged = new EventEmitter();
private image(_: boolean): void {
this.modalHelper
.create(
FileManagerImgComponent,
{
opt: {
multiple: true,
i: { orderby: 0, cat_id: 0, ps: 20 }
}
},
{
size: 1000,
modalOptions: {
nzClosable: false
}
}
)
.subscribe((res: any[]) => {
// delete
const range = this.quill.getSelection(true);
this.quill.updateContents(new Delta().retain(range.index).delete(range.length));
// install all images
for (const ii of res) {
this.quill.updateContents(new Delta().retain(range.index).delete(range.length).insert({ image: ii.mp }, { alt: ii.title }));
}
});
}
@Input()
valueGetter = (quillEditor: any, editorElement: HTMLElement): any => {
let html: string | null = editorElement.children[0].innerHTML;
if (html === '<p><br></p>' || html === '<div><br><div>') {
html = null;
}
let modelValue = html;
if (this.format === 'text') {
modelValue = quillEditor.getText();
} else if (this.format === 'object') {
modelValue = quillEditor.getContents();
} else if (this.format === 'json') {
try {
modelValue = JSON.stringify(quillEditor.getContents());
} catch (e) {
modelValue = quillEditor.getText();
}
}
return modelValue;
};
@Input()
valueSetter = (quillEditor: any, value: any, _format: 'object' | 'html' | 'json'): any => {
if (this.format === 'html') {
return quillEditor.clipboard.convert(value);
} else if (this.format === 'json') {
try {
return JSON.parse(value);
} catch (e) {
return value;
}
}
return value;
};
onModelChange = (_: any) => {};
onModelTouched = () => {};
ngAfterViewInit(): void {
if (isPlatformServer(this.platformId)) {
return;
}
if (this._mode == null) {
this.mode = 'full';
}
const modules: any = this.modules;
this.elementRef.nativeElement.insertAdjacentHTML('beforeend', '<div quill-editor-element></div>');
this.editorElem = this.elementRef.nativeElement.querySelector('[quill-editor-element]');
if (this.style) {
Object.keys(this.style).forEach((key: string) => {
this.renderer.setStyle(this.editorElem, key, this.style[key]);
});
}
this.customOptions.forEach(customOption => {
const newCustomOption = Quill.import(customOption.import);
newCustomOption.whitelist = customOption.whitelist;
Quill.register(newCustomOption, true);
});
this.quill = new Quill(this.editorElem, {
modules,
placeholder: this.placeholder,
readOnly: this.readOnly || false,
theme: this.theme || 'snow',
formats: this.formats,
bounds: this.bounds ? (this.bounds === 'self' ? this.editorElem : this.bounds) : this.doc.body,
strict: this.strict,
scrollingContainer: this.scrollingContainer
});
if (this.content) {
if (this.format === 'object') {
this.quill.setContents(this.content, 'silent');
} else if (this.format === 'text') {
this.quill.setText(this.content, 'silent');
} else if (this.format === 'json') {
try {
this.quill.setContents(JSON.parse(this.content), 'silent');
} catch (e) {
this.quill.setText(this.content, 'silent');
}
} else {
const contents = this.quill.clipboard.convert(this.content);
this.quill.setContents(contents, 'silent');
}
this.quill.history.clear();
}
this.editorCreated.emit(this.quill);
// mark model as touched if editor lost focus
this.selectionChangeEvent = this.quill.on('selection-change', (range: any, oldRange: any, source: string) => {
this.zone.run(() => {
this.selectionChanged.emit({
editor: this.quill,
range,
oldRange,
source
});
if (!range) {
this.onModelTouched();
}
});
});
// update model if text changes
this.textChangeEvent = this.quill.on('text-change', (delta: any, oldDelta: any, source: string) => {
const text = this.quill.getText();
const content = this.quill.getContents();
let html: string | null = this.editorElem.children[0].innerHTML;
if (html === '<p><br></p>' || html === '<div><br><div>') {
html = null;
}
this.zone.run(() => {
this.onModelChange(this.valueGetter(this.quill, this.editorElem));
this.contentChanged.emit({
editor: this.quill,
html,
text,
content,
delta,
oldDelta,
source
});
});
});
}
ngOnDestroy(): void {
if (this.selectionChangeEvent) {
this.selectionChangeEvent.removeListener('selection-change');
}
if (this.textChangeEvent) {
this.textChangeEvent.removeListener('text-change');
}
}
ngOnChanges(changes: SimpleChanges): void {
if (!this.quill) {
return;
}
if (changes.readOnly) {
this.quill.enable(!changes.readOnly.currentValue);
}
if (changes.placeholder) {
this.quill.root.dataset.placeholder = changes.placeholder.currentValue;
}
}
writeValue(currentValue: any): void {
this.content = currentValue;
if (this.quill) {
if (currentValue) {
if (this.format === 'text') {
this.quill.setText(currentValue);
} else {
this.quill.setContents(this.valueSetter(this.quill, this.content, this.format));
}
return;
}
this.quill.setText('');
}
}
registerOnChange(fn: (value: any) => void): void {
this.onModelChange = fn;
}
registerOnTouched(fn: () => void): void {
this.onModelTouched = fn;
}
validate(): any {
if (!this.quill) {
return null;
}
const err: {
minLengthError?: { given: number; minLength: number };
maxLengthError?: { given: number; maxLength: number };
requiredError?: { empty: boolean };
} = {};
let valid = true;
const textLength = this.quill.getText().trim().length;
if (this.minLength && textLength && textLength < this.minLength) {
err.minLengthError = {
given: textLength,
minLength: this.minLength
};
valid = false;
}
if (this.maxLength && textLength > this.maxLength) {
err.maxLengthError = {
given: textLength,
maxLength: this.maxLength
};
valid = false;
}
if (this.required && !textLength) {
err.requiredError = {
empty: true
};
valid = false;
}
return valid ? null : err;
}
}

View File

@ -0,0 +1,13 @@
import { NgModule } from '@angular/core';
import { FileManagerModule } from '../file-manager';
import { EditorComponent } from './editor.component';
const COMPONENTS = [EditorComponent];
@NgModule({
imports: [FileManagerModule],
declarations: COMPONENTS,
exports: COMPONENTS
})
export class EditorModule {}

View File

@ -0,0 +1,39 @@
---
order: 60
title: editor
type: Component
---
Based on [quill](https://github.com/quilljs/quill) WYSIWYG editor, [DEMO](https://preview.ng-alain.com/pro/#/ec/ware/edit/10001).
## Feature
- Integration [file-manager](file-manager)
- Integration [quill-image-resize-module](https://github.com/kensnyder/quill-image-resize-module)
## API
| Property | Description | Type | Default |
| ---------------------- | -------------------------------------- | ------------------- | --------------------- |
| `[(ngModel)]` | Value of quill | `string` | - |
| `[mode]` | Mode of quill | `full,simple` | `full` |
| `[theme]` | Theme of quill | `string` | `snow` |
| `[readOnly]` | Whether to readonly | `boolean` | `false` |
| `[required]` | Whether to required | `boolean` | - |
| `[maxLength]` | The maximum number of quill characters | `number` | - |
| `[minLength]` | The minimum number of quill characters | `number` | - |
| `[placeholder]` | Placeholder of quill | `string` | - |
| `[style]` | Styles of quill | `any` | `{ height: '250px' }` |
| `(editorCreated)` | Quill rendered event | `EventEmitter<any>` | - |
| `(contentChanged)` | Quill content change event | `EventEmitter<any>` | - |
| `(selectionChanged)` | `selection-change` event | `EventEmitter<any>` | - |
## sf widget
Widget name: `editor`.
### ui
| Property | Description | Type | Default |
| -------------------- | -------------------------- | ------------------------- | ------- |
| `(contentChanged)` | Quill content change event | `(value: string) => void` | - |

View File

@ -0,0 +1,2 @@
export * from './editor.component';
export * from './editor.module';

View File

@ -0,0 +1,39 @@
---
order: 60
title: editor
type: Component
---
基于 [quill](https://github.com/quilljs/quill) 富文本编辑器,参考[示例](https://preview.ng-alain.com/pro/#/ec/ware/edit/10001)。
## 特性
- 整合 [file-manager](file-manager)
- 整合 [quill-image-resize-module](https://github.com/kensnyder/quill-image-resize-module)
## API
| 参数 | 说明 | 类型 | 默认值 |
| ---------------------- | -------------------------- | ------------------- | --------------------- |
| `[(ngModel)]` | 值 | `string` | - |
| `[mode]` | 模式 | `full,simple` | `full` |
| `[theme]` | 主题 | `string` | `snow` |
| `[readOnly]` | 是否只读 | `boolean` | `false` |
| `[required]` | 是否必填 | `boolean` | - |
| `[maxLength]` | 最大长度 | `number` | - |
| `[minLength]` | 最少长度 | `number` | - |
| `[placeholder]` | 文本框默认值 | `string` | - |
| `[style]` | 样式,可以决定富文本的高度 | `any` | `{ height: '250px' }` |
| `(editorCreated)` | 初始化完成后事件 | `EventEmitter<any>` | - |
| `(contentChanged)` | 内容变更事件 | `EventEmitter<any>` | - |
| `(selectionChanged)` | `selection-change` 事件 | `EventEmitter<any>` | - |
## sf 小部件
小部件名称:`editor`
### ui
| 参数 | 说明 | 类型 | 默认值 |
| -------------------- | ------------ | ------------------------- | ------ |
| `(contentChanged)` | 内容变更事件 | `(value: string) => void` | - |

View File

@ -0,0 +1,53 @@
<div nz-row [nzGutter]="8">
<div nz-col [nzSpan]="6">
<nz-tree [nzData]="cat.ls" (nzClick)="changeCat($event)"> </nz-tree>
</div>
<div nz-col [nzSpan]="18">
<file-manager #fm [actions]="fmAction" [multiple]="multiple" (selected)="cho($event)" [params]="params">
<ng-template #fmAction>
<button *ngIf="result.length > 0" nz-button nz-dropdown [nzDropdownMenu]="copyMenu" class="ml-md">
<i nz-icon nzType="setting"></i>
<i nz-icon nzType="down"></i>
</button>
<nz-dropdown-menu #copyMenu="nzDropdownMenu">
<ul nz-menu>
<li nz-menu-item (click)="copyData('link')">Copy Link</li>
<li nz-menu-item (click)="copyData('code')">Copy Code</li>
</ul>
</nz-dropdown-menu>
<nz-input-group nzCompact style="display: inherit; width: 270px" class="ml-md">
<nz-select [(ngModel)]="params.orderby" (ngModelChange)="load()">
<nz-option [nzValue]="0" nzLabel="按上传时间从晚到早"></nz-option>
<nz-option [nzValue]="2" nzLabel="按修改时间从晚到早"></nz-option>
<nz-option [nzValue]="4" nzLabel="按修改时间从早到晚"></nz-option>
<nz-option [nzValue]="6" nzLabel="按图片名升序"></nz-option>
<nz-option [nzValue]="8" nzLabel="按图片名降序"></nz-option>
</nz-select>
<input [(ngModel)]="params.q" delay (delayChange)="load()" nz-input placeholder="按文件名称" />
</nz-input-group>
</ng-template>
</file-manager>
</div>
</div>
<nz-card
[nzTitle]="choTpl"
nzType="inner"
*ngIf="multiple && result.length > 0"
[nzBodyStyle]="{ background: 'rgba(204, 204, 204, 0.33)' }"
class="mt-sm"
>
<ng-template #choTpl>
已选图
<small class="pl-md text-grey">(按住拖动可调整顺序)</small>
<button (click)="ok()" nz-button nzType="primary">确认所选</button>
</ng-template>
<div class="file-manager" cdkDropList cdkDropListOrientation="horizontal" (cdkDropListDropped)="drop($event)">
<div class="file-item" *ngFor="let i of result" cdkDrag [title]="i.title">
<div class="file-item__img" [ngStyle]="{ 'background-image': 'url(' + i.mp + ')' }"></div>
<div class="file-item__name">{{ i.title }}</div>
<div class="file-item__pixel">
<span *ngIf="i.is_img">{{ i.width }}x{{ i.height }}</span>
</div>
</div>
</div>
</nz-card>

View File

@ -0,0 +1,108 @@
import { moveItemInArray } from '@angular/cdk/drag-drop';
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, ViewChild } from '@angular/core';
import { _HttpClient } from '@delon/theme';
import { ArrayService, copy } from '@delon/util';
import { NzMessageService } from 'ng-zorro-antd/message';
import { NzModalRef } from 'ng-zorro-antd/modal';
import { NzFormatEmitEvent } from 'ng-zorro-antd/tree';
import { FileManagerComponent } from './file-manager.component';
@Component({
selector: 'file-manager-img',
templateUrl: './file-manager-img.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FileManagerImgComponent implements AfterViewInit {
result: any[] = [];
cat: any = {
ls: [],
item: {}
};
@Input()
params = {
type: 'file',
q: '',
is_img: true,
parent_id: 0,
orderby: 0
};
@Input() multiple: boolean | number = false;
@ViewChild('fm', { static: false }) fm!: FileManagerComponent;
constructor(
private http: _HttpClient,
private arrSrv: ArrayService,
private msg: NzMessageService,
private modal: NzModalRef,
private cdr: ChangeDetectorRef
) {}
ngAfterViewInit(): void {
this.loadCat();
}
copyData(type: 'link' | 'code'): void {
copy(this.result.map(v => this.fm.getCode(v.mp, type)).join('\n')).then(() => this.msg.success('Copy Success'));
}
// #region category
changeCat(e: NzFormatEmitEvent): void {
this.cat.item = e.node!.origin;
this.params.parent_id = e.node!.origin.id;
this.fm.load(1);
this.cdr.detectChanges();
}
loadCat(): void {
this.http.get('/file/folder').subscribe((res: any[]) => {
res.splice(0, 0, { id: 0, title: '所有图片' });
this.cat.ls = this.arrSrv.arrToTreeNode(res, {
cb: (item, parent, deep) => {
item.expanded = deep <= 1;
item.selected = item.id === 0;
}
});
this.cat.item = res[0];
this.cdr.detectChanges();
});
}
// #endregion
load(): void {
this.fm.load(1);
}
cho(i: any): void {
if (i.on === true) {
this.result.splice(this.result.indexOf(i), 1);
i.on = false;
return;
}
if (!this.multiple) {
this.result.push(i);
this.ok();
return;
}
if (typeof this.multiple === 'number' && this.result.length >= this.multiple) {
this.msg.error(`最多只能选取${this.multiple}`);
return;
}
i.on = true;
this.result.push(i);
this.cdr.detectChanges();
}
drop(e: any): void {
moveItemInArray(this.result, e.previousIndex, e.currentIndex);
this.cdr.detectChanges();
}
ok(): void {
this.modal.close(this.result);
}
}

View File

@ -0,0 +1,39 @@
import { Directive, EventEmitter, HostListener, Input, Output } from '@angular/core';
import { ModalHelper } from '@delon/theme';
import { FileManagerImgComponent } from './file-manager-img.component';
@Directive({ selector: '[dialog-img]' })
export class FileManagerImgDirective {
@Input() multiple: boolean | number = false;
@Input() field?: string;
@Output() readonly selected = new EventEmitter<any>();
constructor(private modalHelper: ModalHelper) {}
@HostListener('click')
_click(): void {
this.modalHelper
.create(
FileManagerImgComponent,
{
multiple: this.multiple
},
{
size: 1000,
modalOptions: {
nzClosable: false
}
}
)
.subscribe((res: any) => {
if (Array.isArray(res)) {
let ret = res.length > 0 && !this.multiple ? res[0] : res;
if (this.field && ret) {
ret = ret[this.field];
}
this.selected.emit(ret);
}
});
}
}

View File

@ -0,0 +1,73 @@
<div class="d-flex justify-content-between">
<div class="d-flex align-items-center flex-1">
<nz-upload nzAction="/file" [nzShowUploadList]="false" [nzData]="uploadData" nzMultiple (nzChange)="uploadChange($event)">
<button nz-button nzType="primary" [nzLoading]="loading">
<i nz-icon nzType="upload"></i>
<span>{{ loading ? '上传中' : '选择图像' }}</span>
</button>
</nz-upload>
<ng-template [ngTemplateOutlet]="actions"></ng-template>
</div>
<nz-button-group>
<button nz-button (click)="showType = 'big'" [disabled]="showType === 'big'">
<i nz-icon nzType="appstore"></i>
</button>
<button nz-button (click)="showType = 'small'" [disabled]="showType === 'small'">
<i nz-icon nzType="bars"></i>
</button>
</nz-button-group>
</div>
<nz-spin [nzSpinning]="loading">
<div *ngIf="showType === 'small'" class="file-manager__header">
<div class="file-manager__header-name">Filename</div>
<div class="file-manager__header-pixel">Pixel</div>
<div class="file-manager__header-time">Changed</div>
</div>
<div class="file-manager" [ngClass]="{ 'file-manager__small': showType === 'small' }">
<div *ngIf="path.length > 1" (click)="back()" class="file-item">
<i class="file-item__icon" nz-icon nzType="rollback"></i>
<div class="file-item__name">..</div>
</div>
<div *ngFor="let i of list; let idx = index" class="file-item" [ngClass]="{ 'file-item__selected': i.selected }" (click)="cho(i)">
<i *ngIf="i.type === 'folder'" class="file-item__icon" nz-icon nzType="folder"></i>
<ng-container *ngIf="i.type === 'file'">
<i *ngIf="!i.is_img" class="file-item__icon" nz-icon nzType="file-{{ i.ext }}"></i>
<div class="file-item__img" *ngIf="i.is_img" [ngStyle]="{ 'background-image': 'url(' + i.mp + ')' }"></div>
</ng-container>
<div class="file-item__name">{{ i.title }}</div>
<div class="file-item__pixel">
<span *ngIf="i.is_img">{{ i.width }}x{{ i.height }}</span>
</div>
<div class="file-item__time">{{ i.created | _date }}</div>
<span nz-dropdown [nzDropdownMenu]="actionMenu" class="dd-btn file-item__actions">
<i nz-icon nzType="ellipsis"></i>
</span>
<nz-dropdown-menu #actionMenu="nzDropdownMenu">
<ul nz-menu>
<li nz-menu-item nz-popconfirm nzPopconfirmTitle="确定吗?" (nzOnConfirm)="copyImg(i.id)">Copy</li>
<li nz-menu-item (click)="copyData(i.mp, 'link')">Copy Link</li>
<li nz-menu-item (click)="copyData(i.mp, 'code')">Copy Code</li>
<li nz-menu-item (click)="rename(i)">Rename</li>
<li nz-menu-item (click)="move(i)">Move</li>
<li nz-menu-item nz-popconfirm nzPopconfirmTitle="确定吗?" (nzOnConfirm)="remove(i.id, idx)">Remove</li>
</ul>
</nz-dropdown-menu>
</div>
</div>
<div class="text-center mt-md">
<nz-pagination
[(nzPageIndex)]="s.pi"
(nzPageIndexChange)="load(s.pi)"
[nzPageSize]="s.ps"
[nzTotal]="total"
nzHideOnSinglePage
></nz-pagination>
</div>
<div class="no-data" *ngIf="total === 0">暂无</div>
</nz-spin>
<nz-modal [(nzVisible)]="renameModel" nzTitle="重命名" (nzOnOk)="renameOk()" (nzOnCancel)="renameModel = false">
<input nz-input [(ngModel)]="renameTitle" name="renameTitle" />
</nz-modal>
<nz-modal [(nzVisible)]="moveModel" nzTitle="移动" (nzOnOk)="moveOk()" (nzOnCancel)="moveModel = false">
<nz-tree-select class="d-block" [nzNodes]="folderNodes" nzShowSearch nzExpandAll [(ngModel)]="moveId"> </nz-tree-select>
</nz-modal>

View File

@ -0,0 +1,188 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output, TemplateRef } from '@angular/core';
import { _HttpClient } from '@delon/theme';
import { ArrayService, copy } from '@delon/util';
import { NzMessageService } from 'ng-zorro-antd/message';
import { NzUploadFile } from 'ng-zorro-antd/upload';
@Component({
selector: 'file-manager',
templateUrl: './file-manager.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FileManagerComponent implements OnInit {
private get parent_id(): number {
return this.path[this.path.length - 1];
}
constructor(private http: _HttpClient, private cdr: ChangeDetectorRef, private arrSrv: ArrayService, private msg: NzMessageService) {}
showType: 'big' | 'small' = 'big';
s: any = { orderby: 0, ps: 20, pi: 1, q: '' };
loading = false;
list: any[] = [];
item: any;
path: number[] = [0];
total = 0;
@Input() params: any;
@Input() actions!: TemplateRef<any>;
@Input() multiple: boolean | number = false;
@Output() readonly selected = new EventEmitter<any>();
// #endregion
// #region rename
renameModel = false;
renameTitle = '';
// #endregion
// #region move
moveModel = false;
moveId = '';
folderNodes: any[] = [];
ngOnInit(): void {
this.load(1);
}
getCode(mp: string, type: 'link' | 'code'): string {
return type === 'link' ? mp : `<img src="${mp}">`;
}
// #region op
back(): void {
this.path.pop();
this.load(1);
}
next(i: any): void {
this.path.push(i.id);
this.load(1);
}
load(pi: number): void {
const data = {
...this.s,
pi,
parent_id: this.parent_id,
...this.params
};
this.loading = true;
this.cdr.markForCheck();
this.http.get('/file', data).subscribe((res: any) => {
this.loading = false;
this.list = res.list;
this.total = res.total;
this.cdr.markForCheck();
});
}
cho(i: any): void {
if (i.type === 'folder') {
this.next(i);
return;
}
i.selected = !i.selected;
this.selected.emit(i);
this.cdr.detectChanges();
}
// #endregion
// #region upload
uploadData = () => {
return {
parent_id: this.parent_id
};
};
uploadChange({ file }: { file: NzUploadFile }): void {
if (file.status === 'done') {
this.load(1);
}
}
rename(i: any): void {
this.renameModel = true;
this.item = i;
this.renameTitle = i.title;
}
renameOk(): void {
this.http
.post(`/file/rename`, {
id: this.item.id,
title: this.renameTitle
})
.subscribe(() => {
this.msg.success('Success');
this.item.title = this.renameTitle;
this.renameModel = false;
this.cdr.detectChanges();
});
}
move(i: any): void {
this.moveModel = true;
this.item = i;
this.moveId = i.parent_id;
this.http.get(`/file/folder`).subscribe((res: any[]) => {
res.splice(0, 0, { id: 0, title: '根目录' });
this.folderNodes = this.arrSrv.arrToTree(res, {
cb: item => {
item.key = item.id;
if (item.id === this.moveId) {
item.disabled = true;
}
}
});
this.cdr.detectChanges();
});
}
moveOk(): void {
this.http
.post(`/file/move`, {
id: this.item.id,
moveId: this.moveId
})
.subscribe(() => {
this.msg.success('Success');
this.moveModel = false;
this.list.splice(
this.list.findIndex(w => w.id === this.item.id),
1
);
this.cdr.detectChanges();
});
}
// #endregion
// #region copy
copyImg(id: number): void {
this.http.post(`/file/copy/${id}`).subscribe((res: any) => {
this.msg.success('Success');
this.list.push(res.item);
this.cdr.detectChanges();
});
}
copyData(mp: string, type: 'link' | 'code'): void {
copy(this.getCode(mp, type)).then(() => this.msg.success('Copy Success'));
}
// #endregion
// #region remove
remove(id: number, idx: number): void {
this.http.delete(`/file/${id}`).subscribe(() => {
this.msg.success('Success');
this.list.splice(idx, 1);
this.cdr.detectChanges();
});
}
// #endregion
}

View File

@ -0,0 +1,47 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { AlainThemeModule } from '@delon/theme';
import { NzButtonModule } from 'ng-zorro-antd/button';
import { NzCardModule } from 'ng-zorro-antd/card';
import { NzDropDownModule } from 'ng-zorro-antd/dropdown';
import { NzGridModule } from 'ng-zorro-antd/grid';
import { NzIconModule } from 'ng-zorro-antd/icon';
import { NzInputModule } from 'ng-zorro-antd/input';
import { NzModalModule } from 'ng-zorro-antd/modal';
import { NzPaginationModule } from 'ng-zorro-antd/pagination';
import { NzSelectModule } from 'ng-zorro-antd/select';
import { NzSpinModule } from 'ng-zorro-antd/spin';
import { NzTreeModule } from 'ng-zorro-antd/tree';
import { NzTreeSelectModule } from 'ng-zorro-antd/tree-select';
import { NzUploadModule } from 'ng-zorro-antd/upload';
import { FileManagerImgComponent } from './file-manager-img.component';
import { FileManagerImgDirective } from './file-manager-img.directive';
import { FileManagerComponent } from './file-manager.component';
const COMPONENTS = [FileManagerComponent, FileManagerImgComponent, FileManagerImgDirective];
@NgModule({
imports: [
CommonModule,
FormsModule,
AlainThemeModule.forChild(),
NzUploadModule,
NzButtonModule,
NzIconModule,
NzSpinModule,
NzPaginationModule,
NzDropDownModule,
NzModalModule,
NzInputModule,
NzTreeSelectModule,
NzGridModule,
NzTreeModule,
NzSelectModule,
NzCardModule
],
declarations: COMPONENTS,
exports: COMPONENTS
})
export class FileManagerModule {}

View File

@ -0,0 +1,50 @@
---
order: 70
title: file-manager
type: Component
---
File manager, [DEMO](https://preview.ng-alain.com/pro/#/sys/file-manager).
## API
### file-manager
| Property | Description | Type | Default |
| ------------ | ------------------------------------------- | ------------------- | ------- |
| `[params]` | Extra QueryString request parameter | `any` | - |
| `[actions]` | Custom action template | `TemplateRef<any>` | - |
| `[multiple]` | Whether to mulitple, or specified number | `boolean, number` | `false` |
| `(selected)` | Resource selected event, not include folder | `EventEmitter<any>` | - |
### dialog-img
`dialog-img` is modal dialog based on the `file-manager` component, [DEMO](https://preview.ng-alain.com/pro/#/ec/ware/edit/10001).
| Property | Description | Type | Default |
| ------------ | ------------------------------------------- | ------------------- | ------- |
| `[multiple]` | Whether to mulitple, or specified number | `boolean, number` | `false` |
| `[field]` | Specify to return a field data | `string` | - |
| `(selected)` | Resource selected event, not include folder | `EventEmitter<any>` | - |
**DEMO**
You can only chooses 5 at most, trigger the `cho` event after confirmation.
```html
<button dialog-img [multiple]="5" (selected)="cho(i, $event)"
nz-button type="button" nzType="primary" nzSize="small">
Choose
</button>
```
## sf widget
Widget name: `img`.
### ui
| Property | Description | Type | Default |
|--------------|---------------------------------------------|------------------------|---------|
| `[multiple]` | Whether to mulitple, or specified number | `boolean, number` | `false` |
| `(selected)` | Resource selected event, not include folder | `(value: any) => void` | - |

View File

@ -0,0 +1,4 @@
export * from './file-manager.component';
export * from './file-manager-img.component';
export * from './file-manager-img.directive';
export * from './file-manager.module';

View File

@ -0,0 +1,50 @@
---
order: 70
title: file-manager
type: Component
---
文件管理器,参考[示例](https://preview.ng-alain.com/pro/#/sys/file-manager)。
## API
### file-manager
| 参数 | 说明 | 类型 | 默认值 |
| ------------ | ------------------------------ | ------------------- | ------- |
| `[params]` | 额外 QueryString 请求参数 | `any` | - |
| `[actions]` | 自定义动作 | `TemplateRef<any>` | - |
| `[multiple]` | 是否多选,若最多 N 张 | `boolean, number` | `false` |
| `(selected)` | 当前资源选中事件,不包含文件夹 | `EventEmitter<any>` | - |
### dialog-img
`dialog-img` 是在 `file-manager` 组件的基础上对话框化,参考[示例](https://preview.ng-alain.com/pro/#/ec/ware/edit/10001)。
| 参数 | 说明 | 类型 | 默认值 |
| ------------ | ------------------------------ | ------------------- | ------- |
| `[multiple]` | 是否多选,若最多 N 张 | `boolean, number` | `false` |
| `[field]` | 指定返回某字段的数据 | `string` | - |
| `(selected)` | 当前资源选中事件,不包含文件夹 | `EventEmitter<any>` | - |
**示例**
最多只能选择 5 张,确认后触发 `cho` 事件。
```html
<button dialog-img [multiple]="5" (selected)="cho(i, $event)"
nz-button type="button" nzType="primary" nzSize="small">
选择照片
</button>
```
## sf 小部件
小部件名称:`img`
### ui
| 参数 | 说明 | 类型 | 默认值 |
| ------------ | ------------------------------ | ---------------------- | ------- |
| `[multiple]` | 是否多选,若最多 N 张 | `boolean, number` | `false` |
| `(selected)` | 当前资源选中事件,不包含文件夹 | `(value: any) => void` | - |

View File

@ -0,0 +1,45 @@
---
order: 50
title: masonry
type: Component
---
Based on [masonry](https://masonry.desandro.com/) grid layout, [DEMO](https://preview.ng-alain.com/pro/#/other/gallery).
## Container structure
```html
<div nz-row nzGutter="8" masonry [disabled]="masonryDisabled">
<div
class="masonry__sizer ant-col-md-12 ant-col-xl-6 position-absolute"
></div>
<div
*ngFor="let i of images; let idx=index"
(click)="gallery.open(idx)"
class="masonry__thm mb-sm"
nz-col
nzMd="12"
nzXl="6"
>
<a class="img-thm img-thm__zoom-in">
<i class="img-thm__mask"></i>
<i class="img-thm__icon" nz-icon nzType="search"></i>
<img
class="img-thm__img"
src="{{i.url}}"
(load)="imgLoaded()"
style="min-height: 150px"
/>
</a>
</div>
</div>
```
The `masonry__` prefix class styles is required part.
## API
| Property | Description | Type | Default |
|--------------|------------------------------------------------------|-----------|---------|
| `[masonry]` | [Options](https://masonry.desandro.com/options.html) | `any` | - |
| `[disabled]` | Whether to disable | `boolean` | `false` |

View File

@ -0,0 +1,2 @@
export * from './masonry.directive';
export * from './masonry.module';

View File

@ -0,0 +1,32 @@
---
order: 50
title: masonry
type: Component
---
基于 [masonry](https://masonry.desandro.com/) 瀑布流布局,参考[示例](https://preview.ng-alain.com/pro/#/other/gallery)。
## 容器结构
```html
<div nz-row nzGutter="8" masonry [disabled]="masonryDisabled">
<div class="masonry__sizer ant-col-md-12 ant-col-xl-6 position-absolute"></div>
<div *ngFor="let i of images; let idx=index" (click)="gallery.open(idx)"
class="masonry__thm mb-sm" nz-col nzMd="12" nzXl="6">
<a class="img-thm img-thm__zoom-in">
<i class="img-thm__mask"></i>
<i class="img-thm__icon" nz-icon nzType="search"></i>
<img class="img-thm__img" src="{{i.url}}" (load)="imgLoaded()" style="min-height: 150px">
</a>
</div>
</div>
```
其中 `masonry__` 前缀类样式是必须部分。
## API
| 参数 | 说明 | 类型 | 默认值 |
|--------------|-------------------------------------------------|-----------|---------|
| `[masonry]` | [选项](https://masonry.desandro.com/options.html) | `any` | - |
| `[disabled]` | 是否禁用 | `boolean` | `false` |

View File

@ -0,0 +1,92 @@
import { AfterViewInit, Directive, ElementRef, Input, NgZone, OnChanges, OnDestroy } from '@angular/core';
import { BooleanInput, InputBoolean } from '@delon/util';
import { fromEvent, Subscription } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
declare var Masonry: any;
@Directive({
selector: '[masonry]',
exportAs: 'masonryComp'
})
export class MasonryDirective implements AfterViewInit, OnChanges, OnDestroy {
static ngAcceptInputType_disabled: BooleanInput;
private masonry: any;
private observer?: MutationObserver;
private resize$: Subscription | null = null;
@Input('masonry') options: any;
@Input() @InputBoolean() disabled = false;
constructor(private el: ElementRef, private zone: NgZone) {}
private outsideRender(cb: () => void): void {
this.zone.runOutsideAngular(() => cb());
}
init(): void {
this.destroy();
this.outsideRender(() => {
this.masonry = new Masonry(this.el.nativeElement, {
originLeft: true,
transitionDuration: '0.3s',
itemSelector: '.masonry__thm',
columnWidth: '.masonry__sizer',
...this.options
});
});
}
reload(): void {
this.outsideRender(() => {
if (this.disabled) {
return;
}
this.masonry.reloadItems();
this.masonry.layout();
});
}
private destroy(): void {
this.zone.runOutsideAngular(() => {
if (this.masonry) {
this.masonry.destroy();
}
});
}
private initElChange(): void {
if (this.observer || typeof MutationObserver === 'undefined') {
return;
}
this.zone.runOutsideAngular(() => {
this.observer = new MutationObserver(() => this.reload());
this.observer.observe(this.el.nativeElement, {
childList: true,
subtree: true
});
});
}
ngAfterViewInit(): void {
this.initElChange();
this.resize$ = fromEvent(window, 'resize')
.pipe(debounceTime(50))
.subscribe(() => this.reload());
}
ngOnChanges(): void {
this.init();
}
ngOnDestroy(): void {
this.destroy();
if (this.observer) {
this.observer.disconnect();
}
if (this.resize$) {
this.resize$.unsubscribe();
}
}
}

View File

@ -0,0 +1,11 @@
import { NgModule } from '@angular/core';
import { MasonryDirective } from './masonry.directive';
const COMPONENTS = [MasonryDirective];
@NgModule({
declarations: COMPONENTS,
exports: COMPONENTS
})
export class MasonryModule {}

View File

@ -0,0 +1,34 @@
---
order: 80
title: mouse-focus
type: Component
---
The focus of the mouse, Add class to focus element when mouse over it in a set of elements, Keeping last state when leaving the container.
## DEMO
```html
<ul [mouseFocus]="{ time: 250, itemSelector: 'li', actionClassName: 'active'}">
<li>
<a href="javascript:;">Books</a>
<img src="//qr.liantu.com/api.php?text=https://e.ng-alain.com/">
</li>
<li class="active">
<a href="javascript:;">APP</a>
<img src="//qr.liantu.com/api.php?text=https://e.ng-alain.com/">
</li>
<li>
<a href="javascript:;">WeChat</a>
<img src="//qr.liantu.com/api.php?text=https://e.ng-alain.com/">
</li>
</ul>
```
## API
| Property | Description | Type | Default |
| ----- | ------ | ----- | ------ |
| `[delay]` | Delay (unit: milliseconds) | `number` | `250` |
| `[itemSelector]` | Class name of element item | `string` | `li` |
| `[actionClassName]` | Class name of focus element | `string` | `active` |

View File

@ -0,0 +1,2 @@
export * from './mouse-focus.directive';
export * from './mouse-focus.module';

View File

@ -0,0 +1,34 @@
---
order: 80
title: mouse-focus
type: Component
---
鼠标焦点,在一组元素里鼠标移到某个元素时增加额外一个类名,当离开容器时保留最后一个状态。
## DEMO
```html
<ul [mouseFocus]="{ time: 250, itemSelector: 'li', actionClassName: 'active'}">
<li>
<a href="javascript:;">必读</a>
<img src="//qr.liantu.com/api.php?text=https://e.ng-alain.com/">
</li>
<li class="active">
<a href="javascript:;">APP</a>
<img src="//qr.liantu.com/api.php?text=https://e.ng-alain.com/">
</li>
<li>
<a href="javascript:;">微信</a>
<img src="//qr.liantu.com/api.php?text=https://e.ng-alain.com/">
</li>
</ul>
```
## API
| 参数 | 说明 | 类型 | 默认值 |
|---------------------|---------------|----------|----------|
| `[delay]` | 延迟(单位:毫秒) | `number` | `250` |
| `[itemSelector]` | 项类名 | `string` | `li` |
| `[actionClassName]` | 获得焦点时类名 | `string` | `active` |

View File

@ -0,0 +1,60 @@
import { AfterViewInit, Directive, ElementRef, Input, OnDestroy } from '@angular/core';
import { fromEvent, Subject } from 'rxjs';
import { auditTime, takeUntil } from 'rxjs/operators';
export interface MouseFocusOptions {
delay?: number;
itemSelector?: string;
actionClassName?: string;
}
@Directive({
selector: `[mouseFocus]`,
exportAs: `mouseFocus`
})
export class MouseFocusDirective implements AfterViewInit, OnDestroy {
private unsubscribe$ = new Subject<void>();
private _cog!: MouseFocusOptions;
private _curEl: HTMLElement | null = null;
@Input('mouseFocus')
set config(value: MouseFocusOptions) {
this._cog = {
delay: 250,
itemSelector: 'li',
actionClassName: 'active',
...value
};
}
constructor(private el: ElementRef) {
this.config = {};
}
ngAfterViewInit(): void {
const { el, unsubscribe$, _cog } = this;
let { _curEl } = this;
const parentEl = el.nativeElement as HTMLElement;
fromEvent(parentEl, 'mouseover')
.pipe(takeUntil(unsubscribe$), auditTime(_cog.delay!))
.subscribe((e: Event) => {
const target = (e.target as HTMLElement).closest(_cog.itemSelector!) as HTMLElement;
if (!target || !parentEl.contains(target)) {
return;
}
if (_curEl) {
_curEl.classList.remove(_cog.actionClassName!);
}
target.classList.add(_cog.actionClassName!);
_curEl = target;
});
_curEl = (parentEl.querySelector(`.${_cog.actionClassName}`) as HTMLElement) || null;
}
ngOnDestroy(): void {
const { unsubscribe$ } = this;
unsubscribe$.next();
unsubscribe$.complete();
}
}

View File

@ -0,0 +1,11 @@
import { NgModule } from '@angular/core';
import { MouseFocusDirective } from './mouse-focus.directive';
const COMPONENTS = [MouseFocusDirective];
@NgModule({
declarations: COMPONENTS,
exports: COMPONENTS
})
export class MouseFocusModule {}

View File

@ -0,0 +1,24 @@
---
order: 40
title: scrollbar
type: Component
---
Based on [perfect-scrollbar](http://utatti.github.io/perfect-scrollbar/) perfect custom scrollbar plugin, [DEMO](https://preview.ng-alain.com/pro/#/other/chat).
## API
| Property | Description | Type | Default |
| ----------------- | ------------------------ | ------------------- | ------- |
| `[options]` | [Options](https://github.com/utatti/perfect-scrollbar#options) | `ScrollbarOptions` | - |
| `[disabled]` | Whether to disable | `boolean` | `false` |
| `[psScrollX]` | `ps-scroll-x` event | `EventEmitter<any>` | - |
| `[psScrollY]` | `ps-scroll-y` event | `EventEmitter<any>` | - |
| `[psScrollUp]` | `ps-scroll-up` event | `EventEmitter<any>` | - |
| `[psScrollDown]` | `ps-scroll-down` event | `EventEmitter<any>` | - |
| `[psScrollLeft]` | `ps-scroll-left` event | `EventEmitter<any>` | - |
| `[psScrollRight]` | `ps-scroll-right` event | `EventEmitter<any>` | - |
| `[psXReachStart]` | `ps-x-reach-start` event | `EventEmitter<any>` | - |
| `[psXReachEnd]` | `ps-x-reach-end` event | `EventEmitter<any>` | - |
| `[psYReachStart]` | `ps-y-reach-start` event | `EventEmitter<any>` | - |
| `[psYReachEnd]` | `ps-y-reach-end` event | `EventEmitter<any>` | - |

View File

@ -0,0 +1,3 @@
export * from './scrollbar.directive';
export * from './scrollbar.interface';
export * from './scrollbar.module';

View File

@ -0,0 +1,24 @@
---
order: 40
title: scrollbar
type: Component
---
基于 [perfect-scrollbar](http://utatti.github.io/perfect-scrollbar/) 自定义滚动条插件,参考[示例](https://preview.ng-alain.com/pro/#/other/chat)。
## API
| 参数 | 说明 | 类型 | 默认值 |
| ----- | ------ | ----- | ------ |
| `[options]` | [选项](https://github.com/utatti/perfect-scrollbar#options) | `ScrollbarOptions` | - |
| `[disabled]` | 是否禁用 | `boolean` | `false` |
| `[psScrollX]` | `ps-scroll-x` 事件 | `EventEmitter<any>` | - |
| `[psScrollY]` | `ps-scroll-y` 事件 | `EventEmitter<any>` | - |
| `[psScrollUp]` | `ps-scroll-up` 事件 | `EventEmitter<any>` | - |
| `[psScrollDown]` | `ps-scroll-down` 事件 | `EventEmitter<any>` | - |
| `[psScrollLeft]` | `ps-scroll-left` 事件 | `EventEmitter<any>` | - |
| `[psScrollRight]` | `ps-scroll-right` 事件 | `EventEmitter<any>` | - |
| `[psXReachStart]` | `ps-x-reach-start` 事件 | `EventEmitter<any>` | - |
| `[psXReachEnd]` | `ps-x-reach-end` 事件 | `EventEmitter<any>` | - |
| `[psYReachStart]` | `ps-y-reach-start` 事件 | `EventEmitter<any>` | - |
| `[psYReachEnd]` | `ps-y-reach-end` 事件 | `EventEmitter<any>` | - |

View File

@ -0,0 +1,115 @@
import { AfterViewInit, Directive, ElementRef, EventEmitter, Input, NgZone, OnDestroy, Output } from '@angular/core';
import { toBoolean } from '@delon/util';
import PerfectScrollbar from 'perfect-scrollbar';
import { fromEvent, Subject } from 'rxjs';
import { debounceTime, takeUntil } from 'rxjs/operators';
import { PerfectScrollbarEvent, PerfectScrollbarEvents, ScrollbarOptions } from './scrollbar.interface';
@Directive({
selector: '[scrollbar]',
exportAs: 'scrollbarComp'
})
export class ScrollbarDirective implements AfterViewInit, OnDestroy {
static ngAcceptInputType_options: ScrollbarOptions | string | null | undefined;
private instance: PerfectScrollbar | null = null;
private readonly ngDestroy: Subject<void> = new Subject();
private _disabled = false;
// #region fields
@Input('scrollbar') options?: ScrollbarOptions;
@Input()
set disabled(value: boolean) {
this._disabled = toBoolean(value)!;
if (this._disabled) {
this.ngOnDestroy();
} else {
this.init();
}
}
@Output() readonly psScrollX: EventEmitter<any> = new EventEmitter<any>();
@Output() readonly psScrollY: EventEmitter<any> = new EventEmitter<any>();
@Output() readonly psScrollUp: EventEmitter<any> = new EventEmitter<any>();
@Output() readonly psScrollDown: EventEmitter<any> = new EventEmitter<any>();
@Output() readonly psScrollLeft: EventEmitter<any> = new EventEmitter<any>();
@Output() readonly psScrollRight: EventEmitter<any> = new EventEmitter<any>();
@Output() readonly psXReachStart: EventEmitter<any> = new EventEmitter<any>();
@Output() readonly psXReachEnd: EventEmitter<any> = new EventEmitter<any>();
@Output() readonly psYReachStart: EventEmitter<any> = new EventEmitter<any>();
@Output() readonly psYReachEnd: EventEmitter<any> = new EventEmitter<any>();
// #endregion
scrollToBottom(): void {
this.el.scrollTop = this.el.scrollHeight - this.el.clientHeight;
}
scrollToTop(): void {
this.el.scrollTop = 0;
}
scrollToLeft(): void {
this.el.scrollLeft = 0;
}
scrollToRight(): void {
this.el.scrollLeft = this.el.scrollWidth - this.el.clientWidth;
}
constructor(private elRef: ElementRef, private zone: NgZone) {}
private get el(): HTMLElement {
return this.elRef.nativeElement as HTMLElement;
}
private init(): void {
this.zone.runOutsideAngular(() => {
const options = {
wheelSpeed: 0.5,
swipeEasing: true,
wheelPropagation: false,
minScrollbarLength: 40,
maxScrollbarLength: 300,
...this.options
};
setTimeout(() => {
if (this._disabled) {
return;
}
this.instance = new PerfectScrollbar(this.el, options);
PerfectScrollbarEvents.forEach((eventName: PerfectScrollbarEvent) => {
const eventType = eventName.replace(/([A-Z])/g, c => `-${c.toLowerCase()}`);
fromEvent<Event>(this.el, eventType)
.pipe(debounceTime(20), takeUntil(this.ngDestroy))
.subscribe((event: Event) => {
this[eventName].emit(event);
});
});
}, this.options?.delay || 0);
});
}
ngAfterViewInit(): void {
this.init();
}
ngOnDestroy(): void {
this.ngDestroy.next();
this.ngDestroy.complete();
this.zone.runOutsideAngular(() => {
if (this.instance) {
this.instance.destroy();
}
this.instance = null;
});
}
}

View File

@ -0,0 +1,35 @@
import PerfectScrollbar from 'perfect-scrollbar';
export interface ScrollbarOptions extends PerfectScrollbar.Options {
/**
* 延迟初始化
*/
delay?: number;
}
export type PerfectScrollbarEvent =
| 'psScrollY'
| 'psScrollX'
| 'psScrollUp'
| 'psScrollDown'
| 'psScrollLeft'
| 'psScrollRight'
| 'psYReachEnd'
| 'psYReachStart'
| 'psXReachEnd'
| 'psXReachStart';
export const PerfectScrollbarEvents: PerfectScrollbarEvent[] = [
'psScrollY',
'psScrollX',
'psScrollUp',
'psScrollDown',
'psScrollLeft',
'psScrollRight',
'psYReachEnd',
'psYReachStart',
'psXReachEnd',
'psXReachStart'
];

View File

@ -0,0 +1,11 @@
import { NgModule } from '@angular/core';
import { ScrollbarDirective } from './scrollbar.directive';
const COMPONENTS = [ScrollbarDirective];
@NgModule({
declarations: COMPONENTS,
exports: COMPONENTS
})
export class ScrollbarModule {}

View File

@ -0,0 +1,23 @@
---
order: 10
title: status-label
type: Component
---
Status label, [DEMO](https://preview.ng-alain.com/ms/#/dns/domain)。
## DEMO
```html
<status-label>Normal</status-label>
<status-label type="error" text="Error"></status-label>
<span status-label type="error" text="Error"></span>
```
## API
| Property | Description | Type | Default |
| -------- | --------------------------------------- | ----------------------- | --------- |
| `[type]` | Type of status label | `success,error,warning` | `success` |
| `[icon]` | Whether show icon | `boolean` | `true` |
| `[text]` | Text of status label, or `[ng-content]` | `string` | - |

View File

@ -0,0 +1,2 @@
export * from './status-label.component';
export * from './status-label.module';

View File

@ -0,0 +1,23 @@
---
order: 10
title: status-label
type: Component
---
状态标签,参考[示例](https://preview.ng-alain.com/ms/#/dns/domain)。
## DEMO
```html
<status-label>Normal</status-label>
<status-label type="error" text="Error"></status-label>
<span status-label type="error" text="Error"></span>
```
## API
| 参数 | 说明 | 类型 | 默认值 |
| -------- | ----------------------- | ----------------------- | --------- |
| `[type]` | 类型 | `success,error,warning` | `success` |
| `[icon]` | 是否显示图标 | `boolean` | `true` |
| `[text]` | 文本,或 `[ng-content]` | `string` | - |

View File

@ -0,0 +1,55 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges } from '@angular/core';
import { BooleanInput, InputBoolean } from '@delon/util';
@Component({
selector: 'status-label, [status-label]',
template: `
<i *ngIf="icon" nz-icon [nzType]="iconType" class="pr-xs"></i>
{{ text }}
<ng-content></ng-content>
`,
host: {
'[class.text-success]': `_t=='success'`,
'[class.text-error]': `_t=='error'`,
'[class.text-orange]': `_t=='warning'`
},
changeDetection: ChangeDetectionStrategy.OnPush
})
export class StatusLabelComponent implements OnChanges {
static ngAcceptInputType_icon: BooleanInput;
_t?: string;
iconType!: string;
@Input()
set type(v: 'success' | 'error' | 'warning') {
let iconType: string;
switch (v) {
case 'success':
iconType = 'check-circle';
break;
case 'error':
iconType = 'close-circle';
break;
case 'warning':
default:
iconType = 'exclamation-circle';
break;
}
this._t = v;
this.iconType = iconType;
}
@Input() @InputBoolean() icon = true;
@Input() text?: string;
constructor(private cdr: ChangeDetectorRef) {
this.type = 'success';
}
ngOnChanges(): void {
this.cdr.detectChanges();
}
}

View File

@ -0,0 +1,14 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { NzIconModule } from 'ng-zorro-antd/icon';
import { StatusLabelComponent } from './status-label.component';
const COMPONENTS = [StatusLabelComponent];
@NgModule({
imports: [CommonModule, NzIconModule],
declarations: COMPONENTS,
exports: COMPONENTS
})
export class StatusLabelModule {}