UNPKG

@yelon/abc

Version:

Common business components of ng-yunzai.

1,208 lines (1,196 loc) 54.3 kB
import * as i0 from '@angular/core'; import { inject, EventEmitter, Output, Input, ViewEncapsulation, ChangeDetectionStrategy, Component, Injectable, Directive, InjectionToken, Injector, ChangeDetectorRef, DestroyRef, booleanAttribute, numberAttribute, ViewChild, NgModule, makeEnvironmentProviders, provideEnvironmentInitializer } from '@angular/core'; import { YelonLocaleService, MenuService, YUNZAI_I18N_TOKEN, YelonLocaleModule } from '@yelon/theme'; import { NzMenuDirective, NzMenuItemComponent, NzMenuModule } from 'ng-zorro-antd/menu'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Overlay, ConnectionPositionPair, OverlayModule } from '@angular/cdk/overlay'; import { ComponentPortal } from '@angular/cdk/portal'; import { Subject, Subscription, BehaviorSubject, timer, take, of, filter, debounceTime } from 'rxjs'; import { Directionality } from '@angular/cdk/bidi'; import { Platform } from '@angular/cdk/platform'; import { DOCUMENT, NgTemplateOutlet, CommonModule } from '@angular/common'; import { ActivatedRoute, Router, ROUTER_CONFIGURATION, NavigationStart, NavigationEnd, RouterModule, RouteReuseStrategy } from '@angular/router'; import { NzIconDirective, NzIconModule } from 'ng-zorro-antd/icon'; import { NzTabsComponent, NzTabComponent, NzTabsModule } from 'ng-zorro-antd/tabs'; import { ScrollService } from '@yelon/util/browser'; class ReuseTabContextMenuComponent { locale = inject(YelonLocaleService).valueSignal('reuseTab'); _i18n; set i18n(value) { this._i18n = { ...this.locale(), ...value }; } get i18n() { return this._i18n; } item; event; customContextMenu; close = new EventEmitter(); get includeNonCloseable() { return this.event.ctrlKey; } notify(type) { this.close.next({ type, item: this.item, includeNonCloseable: this.includeNonCloseable }); } ngOnInit() { if (this.includeNonCloseable) this.item.closable = true; } click(e, type, custom) { e.preventDefault(); e.stopPropagation(); if (type === 'close' && !this.item.closable) return; if (type === 'closeRight' && this.item.last) return; if (custom) { if (this.isDisabled(custom)) return; custom.fn(this.item, custom); } this.notify(type); } isDisabled(custom) { return custom.disabled ? custom.disabled(this.item) : false; } closeMenu(event) { if (event.type === 'click' && event.button === 2) return; this.notify(null); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.3", ngImport: i0, type: ReuseTabContextMenuComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.1.3", type: ReuseTabContextMenuComponent, isStandalone: true, selector: "reuse-tab-context-menu", inputs: { i18n: "i18n", item: "item", event: "event", customContextMenu: "customContextMenu" }, outputs: { close: "close" }, host: { listeners: { "document:click": "closeMenu($event)", "document:contextmenu": "closeMenu($event)" } }, ngImport: i0, template: "<ul nz-menu>\n @if (item.active) {\n <li nz-menu-item (click)=\"click($event, 'refresh')\" data-type=\"refresh\" [innerHTML]=\"i18n.refresh\"></li>\n }\n <li\n nz-menu-item\n (click)=\"click($event, 'close')\"\n data-type=\"close\"\n [nzDisabled]=\"!item.closable\"\n [innerHTML]=\"i18n.close\"\n ></li>\n <li nz-menu-item (click)=\"click($event, 'closeOther')\" data-type=\"closeOther\" [innerHTML]=\"i18n.closeOther\"></li>\n <li\n nz-menu-item\n (click)=\"click($event, 'closeRight')\"\n data-type=\"closeRight\"\n [nzDisabled]=\"item.last\"\n [innerHTML]=\"i18n.closeRight\"\n ></li>\n @if (customContextMenu!.length > 0) {\n <li nz-menu-divider></li>\n @for (i of customContextMenu; track $index) {\n <li\n nz-menu-item\n [attr.data-type]=\"i.id\"\n [nzDisabled]=\"isDisabled(i)\"\n (click)=\"click($event, 'custom', i)\"\n [innerHTML]=\"i.title\"\n ></li>\n }\n }\n</ul>\n", dependencies: [{ kind: "directive", type: NzMenuDirective, selector: "[nz-menu]", inputs: ["nzInlineIndent", "nzTheme", "nzMode", "nzInlineCollapsed", "nzSelectable"], outputs: ["nzClick"], exportAs: ["nzMenu"] }, { kind: "component", type: NzMenuItemComponent, selector: "[nz-menu-item]", inputs: ["nzPaddingLeft", "nzDisabled", "nzSelected", "nzDanger", "nzMatchRouterExact", "nzMatchRouter"], exportAs: ["nzMenuItem"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.3", ngImport: i0, type: ReuseTabContextMenuComponent, decorators: [{ type: Component, args: [{ selector: 'reuse-tab-context-menu', host: { '(document:click)': 'closeMenu($event)', '(document:contextmenu)': 'closeMenu($event)' }, changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, imports: [NzMenuDirective, NzMenuItemComponent], template: "<ul nz-menu>\n @if (item.active) {\n <li nz-menu-item (click)=\"click($event, 'refresh')\" data-type=\"refresh\" [innerHTML]=\"i18n.refresh\"></li>\n }\n <li\n nz-menu-item\n (click)=\"click($event, 'close')\"\n data-type=\"close\"\n [nzDisabled]=\"!item.closable\"\n [innerHTML]=\"i18n.close\"\n ></li>\n <li nz-menu-item (click)=\"click($event, 'closeOther')\" data-type=\"closeOther\" [innerHTML]=\"i18n.closeOther\"></li>\n <li\n nz-menu-item\n (click)=\"click($event, 'closeRight')\"\n data-type=\"closeRight\"\n [nzDisabled]=\"item.last\"\n [innerHTML]=\"i18n.closeRight\"\n ></li>\n @if (customContextMenu!.length > 0) {\n <li nz-menu-divider></li>\n @for (i of customContextMenu; track $index) {\n <li\n nz-menu-item\n [attr.data-type]=\"i.id\"\n [nzDisabled]=\"isDisabled(i)\"\n (click)=\"click($event, 'custom', i)\"\n [innerHTML]=\"i.title\"\n ></li>\n }\n }\n</ul>\n" }] }], propDecorators: { i18n: [{ type: Input }], item: [{ type: Input }], event: [{ type: Input }], customContextMenu: [{ type: Input }], close: [{ type: Output }] } }); class ReuseTabContextService { overlay = inject(Overlay); ref = null; i18n; show = new Subject(); close = new Subject(); remove() { if (!this.ref) return; this.ref.detach(); this.ref.dispose(); this.ref = null; } open(context) { this.remove(); const { event, item, customContextMenu } = context; const { x, y } = event; const positions = [ new ConnectionPositionPair({ originX: 'start', originY: 'bottom' }, { overlayX: 'start', overlayY: 'top' }), new ConnectionPositionPair({ originX: 'start', originY: 'top' }, { overlayX: 'start', overlayY: 'bottom' }) ]; const positionStrategy = this.overlay.position().flexibleConnectedTo({ x, y }).withPositions(positions); this.ref = this.overlay.create({ positionStrategy, panelClass: 'reuse-tab__cm', scrollStrategy: this.overlay.scrollStrategies.close() }); const comp = this.ref.attach(new ComponentPortal(ReuseTabContextMenuComponent)); const instance = comp.instance; instance.i18n = this.i18n; instance.item = { ...item }; instance.customContextMenu = customContextMenu; instance.event = event; const sub$ = new Subscription(); sub$.add(instance.close.subscribe((res) => { this.close.next(res); this.remove(); })); comp.onDestroy(() => sub$.unsubscribe()); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.3", ngImport: i0, type: ReuseTabContextService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.1.3", ngImport: i0, type: ReuseTabContextService }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.3", ngImport: i0, type: ReuseTabContextService, decorators: [{ type: Injectable }] }); class ReuseTabContextComponent { srv = inject(ReuseTabContextService); set i18n(value) { this.srv.i18n = value; } change = new EventEmitter(); constructor() { this.srv.show.pipe(takeUntilDestroyed()).subscribe(context => this.srv.open(context)); this.srv.close.pipe(takeUntilDestroyed()).subscribe(res => this.change.emit(res)); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.3", ngImport: i0, type: ReuseTabContextComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.1.3", type: ReuseTabContextComponent, isStandalone: true, selector: "reuse-tab-context", inputs: { i18n: "i18n" }, outputs: { change: "change" }, ngImport: i0, template: ``, isInline: true }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.3", ngImport: i0, type: ReuseTabContextComponent, decorators: [{ type: Component, args: [{ selector: 'reuse-tab-context', template: `` }] }], ctorParameters: () => [], propDecorators: { i18n: [{ type: Input }], change: [{ type: Output }] } }); class ReuseTabContextDirective { srv = inject(ReuseTabContextService); item; customContextMenu; _onContextMenu(event) { this.srv.show.next({ event, item: this.item, customContextMenu: this.customContextMenu }); event.preventDefault(); event.stopPropagation(); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.3", ngImport: i0, type: ReuseTabContextDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "20.1.3", type: ReuseTabContextDirective, isStandalone: true, selector: "[reuse-tab-context-menu]", inputs: { item: ["reuse-tab-context-menu", "item"], customContextMenu: "customContextMenu" }, host: { listeners: { "contextmenu": "_onContextMenu($event)" } }, exportAs: ["reuseTabContextMenu"], ngImport: i0 }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.3", ngImport: i0, type: ReuseTabContextDirective, decorators: [{ type: Directive, args: [{ selector: '[reuse-tab-context-menu]', exportAs: 'reuseTabContextMenu', host: { '(contextmenu)': '_onContextMenu($event)' } }] }], propDecorators: { item: [{ type: Input, args: ['reuse-tab-context-menu'] }], customContextMenu: [{ type: Input }] } }); /** * 复用匹配模式 */ var ReuseTabMatchMode; (function (ReuseTabMatchMode) { /** * (推荐)按菜单 `Menu` 配置 * * 可复用: * - `{ text:'Dashboard' }` * - `{ text:'Dashboard', reuse: true }` * * 不可复用: * - `{ text:'Dashboard', reuse: false }` */ ReuseTabMatchMode[ReuseTabMatchMode["Menu"] = 0] = "Menu"; /** * 按菜单 `Menu` 强制配置 * * 可复用: * - `{ text:'Dashboard', reuse: true }` * * 不可复用: * - `{ text:'Dashboard' }` * - `{ text:'Dashboard', reuse: false }` */ ReuseTabMatchMode[ReuseTabMatchMode["MenuForce"] = 1] = "MenuForce"; /** * 对所有路由有效,可以配合 `excludes` 过滤无须复用路由 */ ReuseTabMatchMode[ReuseTabMatchMode["URL"] = 2] = "URL"; })(ReuseTabMatchMode || (ReuseTabMatchMode = {})); /** * Storage manager that can change rules by implementing `get`, `set` accessors */ const REUSE_TAB_CACHED_MANAGER = new InjectionToken('REUSE_TAB_CACHED_MANAGER'); class ReuseTabCachedManagerFactory { list = []; title = {}; closable = {}; } const REUSE_TAB_STORAGE_KEY = new InjectionToken('REUSE_TAB_STORAGE_KEY'); const REUSE_TAB_STORAGE_STATE = new InjectionToken('REUSE_TAB_STORAGE_STATE'); class ReuseTabLocalStorageState { get(key) { return JSON.parse(localStorage.getItem(key) || '[]') || []; } update(key, value) { localStorage.setItem(key, JSON.stringify(value)); return true; } remove(key) { localStorage.removeItem(key); } } class ReuseTabService { injector = inject(Injector); menuService = inject(MenuService); cached = inject(REUSE_TAB_CACHED_MANAGER); stateKey = inject(REUSE_TAB_STORAGE_KEY); stateSrv = inject(REUSE_TAB_STORAGE_STATE); _inited = false; _max = 10; _keepingScroll = false; _cachedChange = new BehaviorSubject(null); _router$; removeUrlBuffer = null; positionBuffer = {}; componentRef; debug = false; routeParamMatchMode = 'strict'; mode = ReuseTabMatchMode.Menu; /** 排除规则,限 `mode=URL` */ excludes = []; storageState = false; get snapshot() { return this.injector.get(ActivatedRoute).snapshot; } // #region public /** * Get init status * * 是否已经初始化完成 */ get inited() { return this._inited; } /** * Current routing address * * 当前路由地址 */ get curUrl() { return this.getUrl(this.snapshot); } /** * 允许最多复用多少个页面,取值范围 `2-100`,值发生变更时会强制关闭且忽略可关闭条件 */ set max(value) { this._max = Math.min(Math.max(value, 2), 100); for (let i = this.cached.list.length; i > this._max; i--) { this.cached.list.pop(); } } set keepingScroll(value) { this._keepingScroll = value; this.initScroll(); } get keepingScroll() { return this._keepingScroll; } keepingScrollContainer; /** 获取已缓存的路由 */ get items() { return this.cached.list; } /** 获取当前缓存的路由总数 */ get count() { return this.cached.list.length; } /** 订阅缓存变更通知 */ get change() { return this._cachedChange.asObservable(); // .pipe(filter(w => w !== null)); } /** 自定义当前标题 */ set title(value) { const url = this.curUrl; if (typeof value === 'string') value = { text: value }; this.cached.title[url] = value; this.di('update current tag title: ', value); this._cachedChange.next({ active: 'title', url, title: value, list: this.cached.list }); } /** 获取指定路径缓存所在位置,`-1` 表示无缓存 */ index(url) { return this.cached.list.findIndex(w => w.url === url); } /** 获取指定路径缓存是否存在 */ exists(url) { return this.index(url) !== -1; } /** 获取指定路径缓存 */ get(url) { return url ? this.cached.list.find(w => w.url === url) || null : null; } remove(url, includeNonCloseable) { const idx = typeof url === 'string' ? this.index(url) : url; const item = idx !== -1 ? this.cached.list[idx] : null; if (!item || (!includeNonCloseable && !item.closable)) return false; this.destroy(item._handle); this.cached.list.splice(idx, 1); delete this.cached.title[url]; return true; } /** * 根据URL移除标签 * * @param [includeNonCloseable=false] 是否强制包含不可关闭 */ close(url, includeNonCloseable = false) { this.removeUrlBuffer = url; this.remove(url, includeNonCloseable); this._cachedChange.next({ active: 'close', url, list: this.cached.list }); this.di('close tag', url); return true; } /** * 清除右边 * * @param [includeNonCloseable=false] 是否强制包含不可关闭 */ closeRight(url, includeNonCloseable = false) { const start = this.index(url); for (let i = this.count - 1; i > start; i--) { this.remove(i, includeNonCloseable); } this.removeUrlBuffer = null; this._cachedChange.next({ active: 'closeRight', url, list: this.cached.list }); this.di('close right tages', url); return true; } /** * 清除所有缓存 * * @param [includeNonCloseable=false] 是否强制包含不可关闭 */ clear(includeNonCloseable = false) { this.cached.list.forEach(w => { if (!includeNonCloseable && w.closable) this.destroy(w._handle); }); this.cached.list = this.cached.list.filter(w => !includeNonCloseable && !w.closable); this.removeUrlBuffer = null; this._cachedChange.next({ active: 'clear', list: this.cached.list }); this.di('clear all catch'); } /** * 移动缓存数据 * * @param url 要移动的URL地址 * @param position 新位置,下标从 `0` 开始 * * @example * ``` * // source * [ '/a/1', '/a/2', '/a/3', '/a/4', '/a/5' ] * move('/a/1', 2); * // output * [ '/a/2', '/a/3', '/a/1', '/a/4', '/a/5' ] * move('/a/1', -1); * // output * [ '/a/2', '/a/3', '/a/4', '/a/5', '/a/1' ] * ``` */ move(url, position) { const start = this.cached.list.findIndex(w => w.url === url); if (start === -1) return; const data = this.cached.list.slice(); data.splice(position < 0 ? data.length + position : position, 0, data.splice(start, 1)[0]); this.cached.list = data; this._cachedChange.next({ active: 'move', url, position, list: this.cached.list }); } /** * 强制关闭当前路由(包含不可关闭状态),并重新导航至 `newUrl` 路由 */ replace(newUrl) { const url = this.curUrl; this.injector .get(Router) .navigateByUrl(newUrl) .then(() => { if (this.exists(url)) { this.close(url, true); } else { this.removeUrlBuffer = url; } }); } /** * 获取标题,顺序如下: * * 1. 组件内使用 `ReuseTabService.title = 'new title'` 重新指定文本 * 2. 路由配置中 data 属性中包含 titleI18n > title * 3. 菜单数据中 text 属性 * * @param url 指定URL * @param route 指定路由快照 */ getTitle(url, route) { if (this.cached.title[url]) { return this.cached.title[url]; } if (route && route.data && (route.data.titleI18n || route.data.title)) { return { text: route.data.title, i18n: route.data.titleI18n }; } const menu = this.getMenu(url); return menu ? { text: menu.text, i18n: menu.i18n } : { text: url }; } /** * 清除标题缓存 */ clearTitleCached() { this.cached.title = {}; } /** 自定义当前 `closable` 状态 */ set closable(value) { const url = this.curUrl; this.cached.closable[url] = value; this.di('update current tag closable: ', value); this._cachedChange.next({ active: 'closable', closable: value, list: this.cached.list }); } /** * 获取 `closable` 状态,顺序如下: * * 1. 组件内使用 `ReuseTabService.closable = true` 重新指定 `closable` 状态 * 2. 路由配置中 data 属性中包含 `reuseClosable` * 3. 菜单数据中 `reuseClosable` 属性 * * @param url 指定URL * @param route 指定路由快照 */ getClosable(url, route) { if (typeof this.cached.closable[url] !== 'undefined') return this.cached.closable[url]; if (route && route.data && typeof route.data.reuseClosable === 'boolean') return route.data.reuseClosable; const menu = this.mode !== ReuseTabMatchMode.URL ? this.getMenu(url) : null; if (menu && typeof menu.reuseClosable === 'boolean') return menu.reuseClosable; return true; } /** * 清空 `closable` 缓存 */ clearClosableCached() { this.cached.closable = {}; } getTruthRoute(route) { let next = route; while (next.firstChild) next = next.firstChild; return next; } /** * 根据快照获取URL地址 */ getUrl(route) { let next = this.getTruthRoute(route); const segments = []; while (next) { segments.push(next.url.join('/')); next = next.parent; } const url = `/${segments .filter(i => i) .reverse() .join('/')}`; return url; } /** * 检查快照是否允许被复用 */ can(route) { const url = this.getUrl(route); if (url === this.removeUrlBuffer) return false; if (route.data && typeof route.data.reuse === 'boolean') return route.data.reuse; if (this.mode !== ReuseTabMatchMode.URL) { const menu = this.getMenu(url); if (!menu) return false; if (this.mode === ReuseTabMatchMode.Menu) { if (menu.reuse === false) return false; } else { if (!menu.reuse || menu.reuse !== true) return false; } return true; } return !this.isExclude(url); } isExclude(url) { return this.excludes.findIndex(r => r.test(url)) !== -1; } /** * 刷新,触发一个 refresh 类型事件 */ refresh(data) { this._cachedChange.next({ active: 'refresh', data }); } // #endregion // #region privates destroy(_handle) { if (_handle && _handle.componentRef && _handle.componentRef.destroy) _handle.componentRef.destroy(); } di(...args) { if (typeof ngDevMode === 'undefined' || ngDevMode) { if (!this.debug) return; console.warn(...args); } } // #endregion constructor() { if (this.cached == null) { this.cached = { list: [], title: {}, closable: {} }; } } init() { this.initScroll(); this._inited = true; this.loadState(); } loadState() { if (!this.storageState) return; this.cached.list = this.stateSrv.get(this.stateKey).map(v => ({ ...v, title: { text: v.title }, url: v.url, position: v.position })); this._cachedChange.next({ active: 'loadState' }); } getMenu(url) { const menus = this.menuService.getPathByUrl(url); if (!menus || menus.length === 0) return null; return menus.pop(); } runHook(method, comp, type = 'init') { if (typeof comp === 'number') { const item = this.cached.list[comp]; comp = item._handle?.componentRef; } if (comp == null || !comp.instance) { return; } const compThis = comp.instance; const fn = compThis[method]; if (typeof fn !== 'function') { return; } if (method === '_onReuseInit') { fn.call(compThis, type); } else { fn.call(compThis); } } hasInValidRoute(route) { return !route.routeConfig || !!route.routeConfig.loadChildren || !!route.routeConfig.children; } /** * 决定是否允许路由复用,若 `true` 会触发 `store` */ shouldDetach(route) { if (this.hasInValidRoute(route)) return false; this.di('#shouldDetach', this.can(route), this.getUrl(route)); return this.can(route); } saveCache(snapshot, _handle, pos) { const snapshotTrue = this.getTruthRoute(snapshot); const url = this.getUrl(snapshot); const idx = this.index(url); const item = { title: this.getTitle(url, snapshotTrue), url, closable: this.getClosable(url, snapshot), _snapshot: snapshot, _handle }; if (idx < 0) { this.items.splice(pos ?? this.items.length, 0, item); if (this.count > this._max) { // Get the oldest closable location const closeIdx = this.items.findIndex(w => w.url !== url && w.closable); if (closeIdx !== -1) { const closeItem = this.items[closeIdx]; this.remove(closeIdx, false); timer(1) .pipe(take(1)) .subscribe(() => this._cachedChange.next({ active: 'close', url: closeItem.url, list: this.cached.list })); } } } else { this.items[idx] = item; } } /** * 存储 */ store(_snapshot, _handle) { const url = this.getUrl(_snapshot); if (_handle != null) { this.saveCache(_snapshot, _handle); } const list = this.cached.list; const item = { title: this.getTitle(url, _snapshot), closable: this.getClosable(url, _snapshot), position: this.getKeepingScroll(url, _snapshot) ? this.positionBuffer[url] : null, url, _snapshot, _handle }; const idx = this.index(url); // Current handler is null when activate routes // For better reliability, we need to wait for the component to be attached before call _onReuseInit const cahcedComponentRef = list[idx]?._handle?.componentRef; if (_handle == null && cahcedComponentRef != null) { timer(100) .pipe(take(1)) .subscribe(() => this.runHook('_onReuseInit', cahcedComponentRef)); } list[idx] = item; this.removeUrlBuffer = null; this.di('#store', '[override]', url); if (_handle && _handle.componentRef) { this.runHook('_onReuseDestroy', _handle.componentRef); } this._cachedChange.next({ active: 'override', item, list }); } /** * 决定是否允许应用缓存数据 */ shouldAttach(route) { if (this.hasInValidRoute(route)) return false; const url = this.getUrl(route); const data = this.get(url); const ret = !!(data && data._handle); this.di('#shouldAttach', ret, url); if (!ret) { this._cachedChange.next({ active: 'add', url, list: this.cached.list }); } return ret; } /** * 提取复用数据 */ retrieve(route) { if (this.hasInValidRoute(route)) return null; const url = this.getUrl(route); const data = this.get(url); const ret = (data && data._handle) || null; this.di('#retrieve', url, ret); return ret; } /** * 决定是否应该进行复用路由处理 */ shouldReuseRoute(future, curr) { let ret = future.routeConfig === curr.routeConfig; if (!ret) return false; const path = ((future.routeConfig && future.routeConfig.path) || ''); if (path.length > 0 && ~path.indexOf(':')) { if (this.routeParamMatchMode === 'strict') { ret = this.getUrl(future) === this.getUrl(curr); } else { ret = path === ((curr.routeConfig && curr.routeConfig.path) || ''); } } this.di('====================='); this.di('#shouldReuseRoute', ret, `${this.getUrl(curr)}=>${this.getUrl(future)}`, future, curr); return ret; } // #region scroll /** * 获取 `keepingScroll` 状态,顺序如下: * * 1. 路由配置中 data 属性中包含 `keepingScroll` * 2. 菜单数据中 `keepingScroll` 属性 * 3. 组件 `keepingScroll` 值 */ getKeepingScroll(url, route) { if (route && route.data && typeof route.data.keepingScroll === 'boolean') return route.data.keepingScroll; const menu = this.mode !== ReuseTabMatchMode.URL ? this.getMenu(url) : null; if (menu && typeof menu.keepingScroll === 'boolean') return menu.keepingScroll; return this.keepingScroll; } get isDisabledInRouter() { const routerConfig = this.injector.get(ROUTER_CONFIGURATION, {}); return routerConfig.scrollPositionRestoration === 'disabled'; } get ss() { return this.injector.get(ScrollService); } initScroll() { if (this._router$) { this._router$.unsubscribe(); } this._router$ = this.injector.get(Router).events.subscribe(e => { if (e instanceof NavigationStart) { const url = this.curUrl; if (this.getKeepingScroll(url, this.getTruthRoute(this.snapshot))) { this.positionBuffer[url] = this.ss.getScrollPosition(this.keepingScrollContainer); } else { delete this.positionBuffer[url]; } } else if (e instanceof NavigationEnd) { const url = this.curUrl; const item = this.get(url); if (item && item.position && this.getKeepingScroll(url, this.getTruthRoute(this.snapshot))) { if (this.isDisabledInRouter) { this.ss.scrollToPosition(this.keepingScrollContainer, item.position); } else { setTimeout(() => this.ss.scrollToPosition(this.keepingScrollContainer, item.position), 1); } } } }); } // #endregion ngOnDestroy() { const { _cachedChange, _router$ } = this; this.clear(); this.cached.list = []; _cachedChange.complete(); if (_router$) { _router$.unsubscribe(); } } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.3", ngImport: i0, type: ReuseTabService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.1.3", ngImport: i0, type: ReuseTabService }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.3", ngImport: i0, type: ReuseTabService, decorators: [{ type: Injectable }], ctorParameters: () => [] }); class ReuseTabComponent { srv = inject(ReuseTabService, { optional: true }); cdr = inject(ChangeDetectorRef); router = inject(Router); route = inject(ActivatedRoute); i18nSrv = inject(YUNZAI_I18N_TOKEN); doc = inject(DOCUMENT); platform = inject(Platform); stateKey = inject(REUSE_TAB_STORAGE_KEY); stateSrv = inject(REUSE_TAB_STORAGE_STATE); tabset; destroy$ = inject(DestroyRef); _keepingScrollContainer; list = []; item; pos = 0; dir = inject(Directionality).valueSignal; // #region fields mode = ReuseTabMatchMode.Menu; i18n; debug = false; max; tabMaxWidth; excludes; allowClose = true; keepingScroll = false; storageState = false; set keepingScrollContainer(value) { this._keepingScrollContainer = typeof value === 'string' ? this.doc.querySelector(value) : value; } customContextMenu = []; tabBarExtraContent; tabBarGutter; tabBarStyle = null; tabType = 'line'; routeParamMatchMode = 'strict'; disabled = false; titleRender; canClose; change = new EventEmitter(); close = new EventEmitter(); // #endregion genTit(title) { return title.i18n ? this.i18nSrv.fanyi(title.i18n) : title.text; } get curUrl() { return this.srv.getUrl(this.route.snapshot); } genCurItem() { const url = this.curUrl; const snapshotTrue = this.srv.getTruthRoute(this.route.snapshot); return { url, title: this.genTit(this.srv.getTitle(url, snapshotTrue)), closable: this.allowClose && this.srv.count > 0 && this.srv.getClosable(url, snapshotTrue), active: false, last: false, index: 0 }; } genList(notify) { const ls = this.srv.items.map((item, index) => ({ url: item.url, title: this.genTit(item.title), closable: this.allowClose && this.srv.count > 0 && this.srv.getClosable(item.url, item._snapshot), position: item.position, index, active: false, last: false })); const url = this.curUrl; let addCurrent = ls.findIndex(w => w.url === url) === -1; if (notify && notify.active === 'close' && notify.url === url) { addCurrent = false; let toPos = 0; const curItem = this.list.find(w => w.url === url); if (curItem.index === ls.length) { // When closed is last toPos = ls.length - 1; } else if (curItem.index < ls.length) { // Should be actived next tab when closed is middle toPos = Math.max(0, curItem.index); } this.router.navigateByUrl(ls[toPos].url); } if (addCurrent) { const addPos = this.pos + 1; ls.splice(addPos, 0, this.genCurItem()); // Attach to cache this.srv.saveCache(this.route.snapshot, null, addPos); } ls.forEach((item, index) => (item.index = index)); if (ls.length === 1) { ls[0].closable = false; } this.list = ls; this.cdr.detectChanges(); this.updatePos(); } updateTitle(res) { const item = this.list.find(w => w.url === res.url); if (!item) return; item.title = this.genTit(res.title); this.cdr.detectChanges(); } refresh(item) { this.srv.runHook('_onReuseInit', this.pos === item.index ? this.srv.componentRef : item.index, 'refresh'); } saveState() { if (!this.srv.inited || !this.storageState) return; this.stateSrv?.update(this.stateKey, this.list); } // #region UI contextMenuChange(res) { let fn = null; switch (res.type) { case 'refresh': this.refresh(res.item); break; case 'close': this._close(null, res.item.index, res.includeNonCloseable); break; case 'closeRight': fn = () => { this.srv.closeRight(res.item.url, res.includeNonCloseable); this.close.emit(null); }; break; case 'closeOther': fn = () => { this.srv.clear(res.includeNonCloseable); this.close.emit(null); }; break; } if (!fn) { return; } if (!res.item.active && res.item.index <= this.list.find(w => w.active).index) { this._to(res.item.index, fn); } else { fn(); } } _to(index, cb) { index = Math.max(0, Math.min(index, this.list.length - 1)); const item = this.list[index]; this.router.navigateByUrl(item.url).then(res => { if (!res) return; this.item = item; this.change.emit(item); cb?.(); }); } _close(e, idx, includeNonCloseable) { if (e != null) { e.preventDefault(); e.stopPropagation(); } const item = this.list[idx]; (this.canClose ? this.canClose({ item, includeNonCloseable }) : of(true)).pipe(filter(v => v)).subscribe(() => { this.srv.close(item.url, includeNonCloseable); this.close.emit(item); this.cdr.detectChanges(); }); return false; } /** * 设置激活路由的实例,在 `src/app/layout/basic/basic.component.ts` 修改: * * @example * <reuse-tab #reuseTab></reuse-tab> * <router-outlet (activate)="reuseTab.activate($event)" (attach)="reuseTab.activate($event)"></router-outlet> */ activate(instance) { if (this.srv == null) return; this.srv.componentRef = { instance }; } updatePos() { const url = this.srv.getUrl(this.route.snapshot); const ls = this.list.filter(w => w.url === url || !this.srv.isExclude(w.url)); if (ls.length === 0) { return; } const last = ls[ls.length - 1]; const item = ls.find(w => w.url === url); last.last = true; const pos = item == null ? last.index : item.index; ls.forEach((i, idx) => (i.active = pos === idx)); this.pos = pos; // TODO: 目前无法知道为什么 `pos` 无法通过 `nzSelectedIndex` 生效,因此强制使用组件实例的方式来修改,这种方式是安全的 this.tabset.nzSelectedIndex = pos; this.list = ls; this.cdr.detectChanges(); this.saveState(); } // #endregion ngOnInit() { if (!this.platform.isBrowser || this.srv == null) { return; } this.srv.change.pipe(takeUntilDestroyed(this.destroy$)).subscribe(res => { switch (res?.active) { case 'title': this.updateTitle(res); return; case 'override': if (res?.list?.length === this.list.length) { this.updatePos(); return; } break; } this.genList(res); }); this.i18nSrv.change .pipe(filter(() => this.srv.inited), takeUntilDestroyed(this.destroy$), debounceTime(100)) .subscribe(() => this.genList({ active: 'title' })); this.srv.init(); } ngOnChanges(changes) { if (!this.platform.isBrowser || this.srv == null) { return; } if (changes.max) this.srv.max = this.max; if (changes.excludes) this.srv.excludes = this.excludes; if (changes.mode) this.srv.mode = this.mode; if (changes.routeParamMatchMode) this.srv.routeParamMatchMode = this.routeParamMatchMode; if (changes.keepingScroll) { this.srv.keepingScroll = this.keepingScroll; this.srv.keepingScrollContainer = this._keepingScrollContainer; } if (changes.storageState) this.srv.storageState = this.storageState; this.srv.debug = this.debug; this.cdr.detectChanges(); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.3", ngImport: i0, type: ReuseTabComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.1.3", type: ReuseTabComponent, isStandalone: true, selector: "reuse-tab, [reuse-tab]", inputs: { mode: "mode", i18n: "i18n", debug: ["debug", "debug", booleanAttribute], max: ["max", "max", numberAttribute], tabMaxWidth: ["tabMaxWidth", "tabMaxWidth", numberAttribute], excludes: "excludes", allowClose: ["allowClose", "allowClose", booleanAttribute], keepingScroll: ["keepingScroll", "keepingScroll", booleanAttribute], storageState: ["storageState", "storageState", booleanAttribute], keepingScrollContainer: "keepingScrollContainer", customContextMenu: "customContextMenu", tabBarExtraContent: "tabBarExtraContent", tabBarGutter: "tabBarGutter", tabBarStyle: "tabBarStyle", tabType: "tabType", routeParamMatchMode: "routeParamMatchMode", disabled: ["disabled", "disabled", booleanAttribute], titleRender: "titleRender", canClose: "canClose" }, outputs: { change: "change", close: "close" }, host: { properties: { "class.reuse-tab": "true", "class.reuse-tab__line": "tabType === 'line'", "class.reuse-tab__card": "tabType === 'card'", "class.reuse-tab__disabled": "disabled", "class.reuse-tab-rtl": "dir() === 'rtl'" } }, providers: [ReuseTabContextService], viewQueries: [{ propertyName: "tabset", first: true, predicate: ["tabset"], descendants: true }], exportAs: ["reuseTab"], usesOnChanges: true, ngImport: i0, template: "<nz-tabs\n #tabset\n [nzSelectedIndex]=\"pos\"\n [nzAnimated]=\"false\"\n [nzType]=\"tabType\"\n [nzTabBarExtraContent]=\"tabBarExtraContent\"\n [nzTabBarGutter]=\"tabBarGutter\"\n [nzTabBarStyle]=\"tabBarStyle\"\n>\n @for (i of list; track $index) {\n <nz-tab [nzTitle]=\"titleTemplate\" (nzClick)=\"_to($index)\">\n <ng-template #titleTemplate>\n <div\n [reuse-tab-context-menu]=\"i\"\n [customContextMenu]=\"customContextMenu\"\n class=\"reuse-tab__name\"\n [attr.title]=\"i.title\"\n >\n <span [class.reuse-tab__name-width]=\"tabMaxWidth\" [style.max-width.px]=\"tabMaxWidth\">\n @if (titleRender) {\n <ng-template [ngTemplateOutlet]=\"titleRender\" [ngTemplateOutletContext]=\"{ $implicit: i }\" />\n } @else {\n {{ i.title }}\n }\n </span>\n </div>\n @if (i.closable) {\n <nz-icon nzType=\"close\" class=\"reuse-tab__op\" (click)=\"_close($event, $index, false)\" />\n }\n </ng-template>\n </nz-tab>\n }\n</nz-tabs>\n<reuse-tab-context [i18n]=\"i18n\" (change)=\"contextMenuChange($event)\" />\n", dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: NzTabsComponent, selector: "nz-tabs,nz-tabset", inputs: ["nzSelectedIndex", "nzTabPosition", "nzTabBarExtraContent", "nzCanDeactivate", "nzAddIcon", "nzTabBarStyle", "nzType", "nzSize", "nzAnimated", "nzTabBarGutter", "nzHideAdd", "nzCentered", "nzHideAll", "nzLinkRouter", "nzLinkExact", "nzDestroyInactiveTabPane"], outputs: ["nzSelectChange", "nzSelectedIndexChange", "nzTabListScroll", "nzClose", "nzAdd"], exportAs: ["nzTabs"] }, { kind: "component", type: NzTabComponent, selector: "nz-tab", inputs: ["nzTitle", "nzClosable", "nzCloseIcon", "nzDisabled", "nzForceRender"], outputs: ["nzSelect", "nzDeselect", "nzClick", "nzContextmenu"], exportAs: ["nzTab"] }, { kind: "directive", type: ReuseTabContextDirective, selector: "[reuse-tab-context-menu]", inputs: ["reuse-tab-context-menu", "customContextMenu"], exportAs: ["reuseTabContextMenu"] }, { kind: "component", type: ReuseTabContextComponent, selector: "reuse-tab-context", inputs: ["i18n"], outputs: ["change"] }, { kind: "directive", type: NzIconDirective, selector: "nz-icon,[nz-icon]", inputs: ["nzSpin", "nzRotate", "nzType", "nzTheme", "nzTwotoneColor", "nzIconfont"], exportAs: ["nzIcon"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.3", ngImport: i0, type: ReuseTabComponent, decorators: [{ type: Component, args: [{ selector: 'reuse-tab, [reuse-tab]', exportAs: 'reuseTab', host: { '[class.reuse-tab]': 'true', '[class.reuse-tab__line]': `tabType === 'line'`, '[class.reuse-tab__card]': `tabType === 'card'`, '[class.reuse-tab__disabled]': `disabled`, '[class.reuse-tab-rtl]': `dir() === 'rtl'` }, providers: [ReuseTabContextService], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, imports: [ NgTemplateOutlet, NzTabsComponent, NzTabComponent, ReuseTabContextDirective, ReuseTabContextComponent, NzIconDirective ], template: "<nz-tabs\n #tabset\n [nzSelectedIndex]=\"pos\"\n [nzAnimated]=\"false\"\n [nzType]=\"tabType\"\n [nzTabBarExtraContent]=\"tabBarExtraContent\"\n [nzTabBarGutter]=\"tabBarGutter\"\n [nzTabBarStyle]=\"tabBarStyle\"\n>\n @for (i of list; track $index) {\n <nz-tab [nzTitle]=\"titleTemplate\" (nzClick)=\"_to($index)\">\n <ng-template #titleTemplate>\n <div\n [reuse-tab-context-menu]=\"i\"\n [customContextMenu]=\"customContextMenu\"\n class=\"reuse-tab__name\"\n [attr.title]=\"i.title\"\n >\n <span [class.reuse-tab__name-width]=\"tabMaxWidth\" [style.max-width.px]=\"tabMaxWidth\">\n @if (titleRender) {\n <ng-template [ngTemplateOutlet]=\"titleRender\" [ngTemplateOutletContext]=\"{ $implicit: i }\" />\n } @else {\n {{ i.title }}\n }\n </span>\n </div>\n @if (i.closable) {\n <nz-icon nzType=\"close\" class=\"reuse-tab__op\" (click)=\"_close($event, $index, false)\" />\n }\n </ng-template>\n </nz-tab>\n }\n</nz-tabs>\n<reuse-tab-context [i18n]=\"i18n\" (change)=\"contextMenuChange($event)\" />\n" }] }], propDecorators: { tabset: [{ type: ViewChild, args: ['tabset'] }], mode: [{ type: Input }], i18n: [{ type: Input }], debug: [{ type: Input, args: [{ transform: booleanAttribute }] }], max: [{ type: Input, args: [{ transform: numberAttribute }] }], tabMaxWidth: [{ type: Input, args: [{ transform: numberAttribute }] }], excludes: [{ type: Input }], allowClose: [{ type: Input, args: [{ transform: booleanAttribute }] }], keepingScroll: [{ type: Input, args: [{ transform: booleanAttribute }] }], storageState: [{ type: Input, args: [{ transform: booleanAttribute }] }], keepingScrollContainer: [{ type: Input }], customContextMenu: [{ type: Input }], tabBarExtraContent: [{ type: Input }], tabBarGutter: [{ type: Input }], tabBarStyle: [{ type: Input }], tabType: [{ type: Input }], routeParamMatchMode: [{ type: Input }], disabled: [{ type: Input, args: [{ transform: booleanAttribute }] }], titleRender: [{ type: Input }], canClose: [{ type: Input }], change: [{ type: Output }], close: [{ type: Output }] } }); class ReuseTabStrategy { srv = inject(ReuseTabService); shouldDetach(route) { return this.srv.shouldDetach(route); } store(route, handle) { this.srv.store(route, handle); } shouldAttach(route) { return this.srv.shouldAttach(route); } retrieve(route) { return this.srv.retrieve(route); } shouldReuseRoute(future, curr) { return this.srv.shouldReuseRoute(future, curr); } } const COMPONENTS = [ReuseTabComponent]; const NOEXPORTS = [ReuseTabContextMenuComponent, ReuseTabContextComponent, ReuseTabContextDirective]; class ReuseTabModule { static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.1.3", ngImport: i0, type: ReuseTabModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "20.1.3", ngImport: i0, type: ReuseTabModule, imports: [CommonModule, RouterModule, YelonLocaleModule, NzMenuModule, NzTabsModule, NzIconModule, OverlayModule, ReuseTabComponent, ReuseTabContextMenuComponent, ReuseTabContextComponent, ReuseTabContextDirective], exports: [ReuseTabComponent] }); static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "20.1.3", ngImport: i0, type: ReuseTabModule, providers: [ { provide: REUSE_TAB_STORAGE_KEY, useValue: '_reuse-tab-state' }, { provide: REUSE_TAB_STORAGE_STATE, useFactory: () => new ReuseTabLocalStorageState() }, { provide: REUSE_TAB_CACHED_MANAGER, useFactory: () => new ReuseTabCachedManagerFactory() } ], imports: [CommonModule, RouterModule, YelonLocaleModule, NzMenuModule, NzTabsModule, NzIconModule, OverlayModule, COMPONENTS, ReuseTabContextMenuComponent] }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.1.3", ngImport: i0, type: ReuseTabModule, decorators: [{ type: NgModule, args: [{ imports: [ CommonModule, RouterModule, YelonLocaleModule, NzMenuModule, NzTabsModule, NzIconModule, OverlayModule, ...COMPONENTS, ...NOEXPORTS ], providers: [ {