release: v1.0.0

This commit is contained in:
acgnhiki
2021-08-11 13:33:21 +08:00
parent ca93684a70
commit 01b11768bd
2008 changed files with 50705 additions and 0 deletions

17
webapp/.browserslistrc Normal file
View File

@@ -0,0 +1,17 @@
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
# For additional information regarding the format and rule options, please see:
# https://github.com/browserslist/browserslist#queries
# For the full list of supported browsers by the Angular framework, please see:
# https://angular.io/guide/browser-support
# You can see what browsers were selected by your queries by running:
# npx browserslist
last 1 Chrome version
last 1 Firefox version
last 2 Edge major versions
last 2 Safari major versions
last 2 iOS major versions
Firefox ESR
not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line.

16
webapp/.editorconfig Normal file
View File

@@ -0,0 +1,16 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
[*.md]
max_line_length = off
trim_trailing_whitespace = false

51
webapp/.eslintrc.json Normal file
View File

@@ -0,0 +1,51 @@
{
"root": true,
"ignorePatterns": [
"projects/**/*"
],
"overrides": [
{
"files": [
"*.ts"
],
"parserOptions": {
"project": [
"tsconfig.json",
"e2e/tsconfig.json"
],
"createDefaultProgram": true
},
"extends": [
"plugin:@angular-eslint/recommended",
"plugin:@angular-eslint/template/process-inline-templates"
],
"rules": {
"@angular-eslint/directive-selector": [
"error",
{
"type": "attribute",
"prefix": "app",
"style": "camelCase"
}
],
"@angular-eslint/component-selector": [
"error",
{
"type": "element",
"prefix": "app",
"style": "kebab-case"
}
]
}
},
{
"files": [
"*.html"
],
"extends": [
"plugin:@angular-eslint/template/recommended"
],
"rules": {}
}
]
}

45
webapp/.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
# See http://help.github.com/ignore-files/ for more about ignoring files.
# compiled output
/dist
/tmp
/out-tsc
# Only exists if Bazel was run
/bazel-out
# dependencies
/node_modules
# profiling files
chrome-profiler-events*.json
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# misc
/.sass-cache
/connect.lock
/coverage
/libpeerconnection.log
npm-debug.log
yarn-error.log
testem.log
/typings
# System Files
.DS_Store
Thumbs.db

27
webapp/README.md Normal file
View File

@@ -0,0 +1,27 @@
# Webapp
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 12.0.0.
## Development server
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files.
## Code scaffolding
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
## Build
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `--prod` flag for a production build.
## Running unit tests
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Running end-to-end tests
Run `ng e2e` to execute the end-to-end tests via a platform of your choice.
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page.

133
webapp/angular.json Normal file
View File

@@ -0,0 +1,133 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"blrec": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
},
"@schematics/angular:application": {
"strict": true
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "../src/blrec/data/webapp",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
"src/favicon.ico",
"src/assets",
{
"glob": "**/*",
"input": "./node_modules/@ant-design/icons-angular/src/inline-svg/",
"output": "/assets/"
},
"src/manifest.webmanifest"
],
"styles": [
"src/theme.less",
"src/styles.scss"
],
"scripts": [],
"serviceWorker": true,
"ngswConfigPath": "ngsw-config.json"
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "2mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
}
],
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"outputHashing": "all"
},
"development": {
"buildOptimizer": false,
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"browserTarget": "blrec:build:production"
},
"development": {
"browserTarget": "blrec:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "blrec:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.js",
"inlineStyleLanguage": "scss",
"assets": [
"src/favicon.ico",
"src/assets",
"src/manifest.webmanifest"
],
"styles": [
"src/styles.scss"
],
"scripts": []
}
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"src/**/*.ts",
"src/**/*.html"
]
}
}
}
}
},
"defaultProject": "blrec",
"cli": {
"defaultCollection": "@angular-eslint/schematics"
}
}

44
webapp/karma.conf.js Normal file
View File

@@ -0,0 +1,44 @@
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage'),
require('@angular-devkit/build-angular/plugins/karma')
],
client: {
jasmine: {
// you can add configuration options for Jasmine here
// the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
// for example, you can disable the random execution with `random: false`
// or set a specific seed with `seed: 4321`
},
clearContext: false // leave Jasmine Spec Runner output visible in browser
},
jasmineHtmlReporter: {
suppressAll: true // removes the duplicated traces
},
coverageReporter: {
dir: require('path').join(__dirname, './coverage/webapp'),
subdir: '.',
reporters: [
{ type: 'html' },
{ type: 'text-summary' }
]
},
reporters: ['progress', 'kjhtml'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false,
restartOnFileChange: true
});
};

30
webapp/ngsw-config.json Normal file
View File

@@ -0,0 +1,30 @@
{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/index.html",
"/manifest.webmanifest",
"/*.css",
"/*.js"
]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": [
"/assets/**",
"/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)"
]
}
}
]
}

15156
webapp/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

55
webapp/package.json Normal file
View File

@@ -0,0 +1,55 @@
{
"name": "blrec",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"dependencies": {
"@angular/animations": "^12.2.0",
"@angular/cdk": "~12.2.0",
"@angular/common": "~12.2.0",
"@angular/compiler": "^12.2.0",
"@angular/core": "^12.2.0",
"@angular/forms": "~12.2.0",
"@angular/platform-browser": "~12.2.0",
"@angular/platform-browser-dynamic": "^12.2.0",
"@angular/router": "^12.2.0",
"@angular/service-worker": "~12.2.0",
"filesize": "^6.4.0",
"lodash-es": "^4.17.21",
"ng-zorro-antd": "^12.0.1",
"ngx-logger": "^4.2.2",
"rxjs": "~6.6.0",
"tslib": "^2.3.0",
"zone.js": "~0.11.4"
},
"devDependencies": {
"@angular-devkit/build-angular": "^12.2.0",
"@angular-eslint/builder": "12.0.0",
"@angular-eslint/eslint-plugin": "12.0.0",
"@angular-eslint/eslint-plugin-template": "12.0.0",
"@angular-eslint/schematics": "12.0.0",
"@angular-eslint/template-parser": "12.0.0",
"@angular/cli": "^12.2.0",
"@angular/compiler-cli": "^12.2.0",
"@types/jasmine": "~3.6.0",
"@types/lodash-es": "^4.17.4",
"@types/node": "^12.20.19",
"@typescript-eslint/eslint-plugin": "4.23.0",
"@typescript-eslint/parser": "4.23.0",
"eslint": "^7.32.0",
"jasmine-core": "~3.7.0",
"karma": "^6.3.4",
"karma-chrome-launcher": "~3.1.0",
"karma-coverage": "~2.0.3",
"karma-jasmine": "~4.0.0",
"karma-jasmine-html-reporter": "^1.7.0",
"source-map-explorer": "^2.5.2",
"typescript": "~4.2.3"
}
}

View File

@@ -0,0 +1,20 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AboutComponent } from './about.component';
import { AppInfoResolver } from './shared/services/app-info.resolver';
const routes: Routes = [
{
path: '',
component: AboutComponent,
resolve: {
appInfo: AppInfoResolver,
},
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class AboutRoutingModule {}

View File

@@ -0,0 +1,7 @@
<div class="inner-content">
<div class="about-page">
<app-page-section>
<app-info-list [appInfo]="appInfo"></app-info-list>
</app-page-section>
</div>
</div>

View File

@@ -0,0 +1,10 @@
@use '../shared/styles/layout';
@use '../shared/styles/list';
.inner-content {
@extend %inner-content;
.about-page {
@extend %inner-page;
}
}

View File

@@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AboutComponent } from './about.component';
describe('AboutComponent', () => {
let component: AboutComponent;
let fixture: ComponentFixture<AboutComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ AboutComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(AboutComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,24 @@
import { Component, OnInit, ChangeDetectorRef } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { AppInfo } from '../core/models/app.models';
@Component({
selector: 'app-about',
templateUrl: './about.component.html',
styleUrls: ['./about.component.scss'],
})
export class AboutComponent implements OnInit {
appInfo!: AppInfo;
constructor(
private changeDetector: ChangeDetectorRef,
private route: ActivatedRoute
) {}
ngOnInit(): void {
this.route.data.subscribe((data) => {
this.appInfo = data.appInfo;
this.changeDetector.markForCheck();
});
}
}

View File

@@ -0,0 +1,25 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AboutRoutingModule } from './about-routing.module';
import { SharedModule } from '../shared/shared.module';
import { AboutComponent } from './about.component';
import { InfoListComponent } from './info-list/info-list.component';
import { AppInfoResolver } from './shared/services/app-info.resolver';
@NgModule({
declarations: [
AboutComponent,
InfoListComponent,
],
imports: [
CommonModule,
AboutRoutingModule,
SharedModule,
],
providers: [
AppInfoResolver,
]
})
export class AboutModule { }

View File

@@ -0,0 +1,50 @@
<ul class="info-list">
<li class="info-item">
<span class="label">当前版本</span
><span class="desc">{{ appInfo.version }}</span>
</li>
<li class="info-item">
<span class="label">最新版本</span
><span class="desc">{{ latestVesion$ | async }}</span>
</li>
<li class="info-item">
<span class="label">项目主页</span
><span class="desc"
><a href="https://github.com/acgnhiki/blrec" target="_blank"
>https://github.com/acgnhiki/blrec</a
></span
>
</li>
<li class="info-item">
<span class="label">问题反馈</span
><span class="desc"
><a href="https://github.com/acgnhiki/blrec/issues" target="_blank"
>https://github.com/acgnhiki/blrec/issues</a
></span
>
</li>
<li class="info-item">
<span class="label">许可协议</span
><span class="desc"
><a href="https://choosealicense.com/licenses/gpl-3.0" target="_blank"
>GNU GPLv3</a
></span
>
</li>
<li class="info-item">
<span class="label">联系方式</span
><span class="desc"
><a href="mailto:acgnhiki@outlook.com" target="_blank"
>acgnhiki@outlook.com</a
></span
>
</li>
<li class="info-item">
<span class="label">投喂赞助</span
><span class="desc"
><a href="https://afdian.net/@acgnhiki" target="_blank"
>https://afdian.net/@acgnhiki</a
></span
>
</li>
</ul>

View File

@@ -0,0 +1,13 @@
@use '../../shared/styles/list';
.info-list {
@include list.reset-list;
.info-item {
@extend %list-item;
.label {
@extend %item-label;
}
}
}

View File

@@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { InfoListComponent } from './info-list.component';
describe('InfoListComponent', () => {
let component: InfoListComponent;
let fixture: ComponentFixture<InfoListComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ InfoListComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(InfoListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,22 @@
import { Component, ChangeDetectionStrategy, Input } from '@angular/core';
import { Observable } from 'rxjs';
import { AppInfo } from 'src/app/core/models/app.models';
import { UpdateService } from 'src/app/core/services/update.service';
@Component({
selector: 'app-info-list',
templateUrl: './info-list.component.html',
styleUrls: ['./info-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InfoListComponent {
@Input() appInfo!: AppInfo;
latestVesion$: Observable<string>;
constructor(updateService: UpdateService) {
this.latestVesion$ = updateService.getLatestVerisonString();
}
}

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { AppInfoResolver } from './app-info.resolver';
describe('AppInfoResolver', () => {
let resolver: AppInfoResolver;
beforeEach(() => {
TestBed.configureTestingModule({});
resolver = TestBed.inject(AppInfoResolver);
});
it('should be created', () => {
expect(resolver).toBeTruthy();
});
});

View File

@@ -0,0 +1,40 @@
import { Injectable } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import {
Resolve,
RouterStateSnapshot,
ActivatedRouteSnapshot,
} from '@angular/router';
import { Observable } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { NGXLogger } from 'ngx-logger';
import { NzNotificationService } from 'ng-zorro-antd/notification';
import { AppInfo } from 'src/app/core/models/app.models';
import { AppService } from 'src/app/core/services/app.service';
import { retry } from 'src/app/shared/rx-operators';
@Injectable()
export class AppInfoResolver implements Resolve<AppInfo> {
constructor(
private logger: NGXLogger,
private notification: NzNotificationService,
private appService: AppService
) {}
resolve(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): Observable<AppInfo> {
return this.appService.getAppInfo().pipe(
retry(3, 300),
catchError((error: HttpErrorResponse) => {
this.logger.error('Failed to get app info:', error);
this.notification.error('获取后端应用信息出错', error.message, {
nzDuration: 0,
});
throw error;
})
);
}
}

View File

@@ -0,0 +1,38 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule, PreloadAllModules } from '@angular/router';
import { PageNotFoundComponent } from './page-not-found/page-not-found.component';
import { RouteScrollBehaviour } from './core/services/router-scroll.service.intf';
const routes: Routes = [
{
path: 'tasks',
loadChildren: () =>
import('./tasks/tasks.module').then((m) => m.TasksModule),
},
{
path: 'settings',
loadChildren: () =>
import('./settings/settings.module').then((m) => m.SettingsModule),
data: {
scrollBehavior: RouteScrollBehaviour.KEEP_POSITION,
},
},
{
path: 'about',
loadChildren: () =>
import('./about/about.module').then((m) => m.AboutModule),
},
{ path: '', pathMatch: 'full', redirectTo: '/tasks' },
{ path: '**', component: PageNotFoundComponent },
];
@NgModule({
imports: [
RouterModule.forRoot(routes, {
preloadingStrategy: PreloadAllModules,
}),
],
exports: [RouterModule],
})
export class AppRoutingModule {}

View File

@@ -0,0 +1,108 @@
<nz-layout>
<ng-container *ngIf="!useDrawer" [ngTemplateOutlet]="sider"></ng-container>
<ng-template #sider>
<nz-sider
class="sidebar"
[nzTheme]="theme"
[nzTrigger]="null"
nzCollapsible
[nzCollapsedWidth]="65"
[(nzCollapsed)]="collapsed"
>
<a href="/" title="Home" alt="Home">
<div class="sidebar-header" [class.collapsed]="collapsed">
<div class="app-logo-container">
<img class="app-logo" alt="Logo" src="assets/images/logo.png" />
</div>
<h1 class="app-title">{{ title }}</h1>
</div>
</a>
<nav class="sidebar-menu">
<ul
nz-menu
nzMode="inline"
[nzTheme]="theme"
[nzInlineCollapsed]="collapsed"
>
<li
nz-menu-item
nzMatchRouter="true"
nz-tooltip
nzTooltipPlacement="right"
[nzTooltipTitle]="collapsed ? '任务' : ''"
>
<i nz-icon nzType="unordered-list" nzTheme="outline"></i>
<span><a routerLink="/tasks">任务</a></span>
</li>
<li
nz-menu-item
nzMatchRouter="true"
nz-tooltip
nzTooltipPlacement="right"
[nzTooltipTitle]="collapsed ? '设置' : ''"
>
<i nz-icon nzType="setting" nzTheme="outline"></i>
<span><a routerLink="/settings">设置</a></span>
</li>
<li
nz-menu-item
nzMatchRouter="true"
nz-tooltip
nzTooltipPlacement="right"
[nzTooltipTitle]="collapsed ? '关于' : ''"
>
<i nz-icon nzType="info-circle" nzTheme="outline"></i>
<span><a routerLink="/about">关于</a></span>
</li>
</ul>
</nav>
</nz-sider>
</ng-template>
<nz-layout>
<nz-header class="app-header">
<div class="sidebar-trigger">
<i
nz-icon
[nzType]="collapsed ? 'menu-unfold' : 'menu-fold'"
(click)="collapsed = !collapsed"
></i>
</div>
<div class="icon-actions">
<a
class="external-link"
href="https://github.com/acgnhiki/blrec"
title="GitHub"
target="_blank"
><i nz-icon nzType="github"></i
></a>
</div>
</nz-header>
<nz-content class="main-content">
<nz-spin
class="spinner"
[nzSize]="'large'"
[nzSpinning]="loading"
*ngIf="loading"
></nz-spin>
<router-outlet></router-outlet>
</nz-content>
</nz-layout>
</nz-layout>
<nz-drawer
*ngIf="useDrawer"
nzWidth="200px"
nzPlacement="left"
[nzBodyStyle]="{ padding: '0', overflow: 'hidden' }"
[nzClosable]="false"
[nzVisible]="!collapsed"
(nzOnClose)="collapsed = true"
>
<ng-container *nzDrawerContent>
<nz-layout>
<ng-container [ngTemplateOutlet]="sider"></ng-container>
</nz-layout>
</ng-container>
</nz-drawer>

View File

@@ -0,0 +1,136 @@
@use './shared/styles/layout';
@use './shared/styles/common';
$app-header-height: 64px;
$app-logo-size: 32px;
:host {
--app-header-height: #{$app-header-height};
--app-logo-size: #{$app-logo-size};
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
& > nz-layout {
@include layout.full-width-height;
}
}
.sidebar {
--app-header-height: #{$app-header-height};
--app-logo-size: #{$app-logo-size};
@mixin transition($property) {
transition-property: #{$property};
transition-duration: 0.3s;
transition-timing-function: cubic-bezier(0.645, 0.045, 0.355, 1);
}
position: relative;
z-index: 10;
min-height: 100vh;
border-right: 1px solid #f0f0f0;
.sidebar-header {
display: flex;
align-items: center;
height: var(--app-header-height);
overflow: hidden;
.app-logo-container {
flex: none;
width: var(--app-header-height);
height: var(--app-header-height);
@include layout.center-content;
.app-logo {
width: var(--app-logo-size);
height: var(--app-logo-size);
}
}
.app-title {
font-family: Avenir, Helvetica Neue, Arial, Helvetica, sans-serif;
font-size: 1rem;
font-weight: 600;
margin: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: clip;
opacity: 1;
@include transition("width, opacity");
}
&.collapsed {
.app-title {
opacity: 0;
}
}
}
.sidebar-menu {
ul {
width: 100%;
}
}
}
.app-header {
display: flex;
align-items: center;
position: relative;
width: 100%;
height: var(--app-header-height);
margin: 0;
padding: 0;
z-index: 2;
background: #fff;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
.sidebar-trigger {
--icon-size: 20px;
@include layout.center-content;
height: 100%;
width: var(--app-header-height);
cursor: pointer;
transition: all 0.3s, padding 0s;
&:hover {
color: #1890ff;
}
i {
font-size: var(--icon-size);
}
}
.icon-actions {
--icon-size: 24px;
@include layout.center-content;
height: 100%;
margin-left: auto;
margin-right: calc((var(--app-header-height) - var(--icon-size)) / 2);
.external-link {
@include layout.center-content;
color: black;
i {
font-size: var(--icon-size);
}
}
}
}
.main-content {
overflow: hidden;
}

View File

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

View File

@@ -0,0 +1,73 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnDestroy,
} from '@angular/core';
import { Router, NavigationEnd, NavigationStart } from '@angular/router';
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent implements OnDestroy {
title = 'B 站直播录制';
theme: 'light' | 'dark' = 'light';
loading = false;
collapsed = false;
useDrawer = false;
destroyed = new Subject<void>();
constructor(
router: Router,
changeDetector: ChangeDetectorRef,
breakpointObserver: BreakpointObserver
) {
router.events.subscribe((event) => {
if (event instanceof NavigationStart) {
this.loading = true;
// close the drawer
if (this.useDrawer) {
this.collapsed = true;
}
} else if (event instanceof NavigationEnd) {
this.loading = false;
}
});
// use drawer as side nav for x-small device
breakpointObserver
.observe(Breakpoints.XSmall)
.pipe(takeUntil(this.destroyed))
.subscribe((state) => {
this.useDrawer = state.matches;
// ensure the drawer is closed
if (this.useDrawer) {
this.collapsed = true;
}
changeDetector.markForCheck();
});
// display task cards as many as possible
// max-width: card-width(400px) * 2 + gutter(12px) + padding(12px) * 2 + sidenav(200px)
breakpointObserver
.observe('(max-width: 1036px)')
.pipe(takeUntil(this.destroyed))
.subscribe((state) => {
this.collapsed = state.matches;
changeDetector.markForCheck();
});
}
ngOnDestroy() {
this.destroyed.next();
this.destroyed.complete();
}
}

View File

@@ -0,0 +1,69 @@
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';
import { LayoutModule } from '@angular/cdk/layout';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { registerLocaleData } from '@angular/common';
import zh from '@angular/common/locales/zh';
import { ServiceWorkerModule } from '@angular/service-worker';
import { LoggerModule } from 'ngx-logger';
import { NZ_I18N } from 'ng-zorro-antd/i18n';
import { zh_CN } from 'ng-zorro-antd/i18n';
import { NzLayoutModule } from 'ng-zorro-antd/layout';
import { NzDrawerModule } from 'ng-zorro-antd/drawer';
import { NzMenuModule } from 'ng-zorro-antd/menu';
import { NzToolTipModule } from 'ng-zorro-antd/tooltip';
import { NzBackTopModule } from 'ng-zorro-antd/back-top';
import { NzSpinModule } from 'ng-zorro-antd/spin';
import { NzMessageModule } from 'ng-zorro-antd/message';
import { NzNotificationModule } from 'ng-zorro-antd/notification';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { CoreModule } from './core/core.module';
import { IconsProviderModule } from './icons-provider.module';
import { PageNotFoundComponent } from './page-not-found/page-not-found.component';
import { environment } from '../environments/environment';
import { httpInterceptorProviders } from './core/http-interceptors';
registerLocaleData(zh);
@NgModule({
declarations: [AppComponent, PageNotFoundComponent],
imports: [
BrowserModule,
AppRoutingModule,
FormsModule,
HttpClientModule,
LayoutModule,
BrowserAnimationsModule,
ServiceWorkerModule.register('ngsw-worker.js', {
enabled: environment.production,
// Register the ServiceWorker as soon as the app is stable
// or after 30 seconds (whichever comes first).
registrationStrategy: 'registerWhenStable:30000',
}),
NzLayoutModule,
NzDrawerModule,
NzMenuModule,
NzToolTipModule,
NzBackTopModule,
NzSpinModule,
NzMessageModule,
NzNotificationModule,
IconsProviderModule,
LoggerModule.forRoot({
level: environment.ngxLoggerLevel,
// serverLoggingUrl: '/api/logs',
// serverLogLevel: NgxLoggerLevel.ERROR,
}),
CoreModule,
],
providers: [{ provide: NZ_I18N, useValue: zh_CN }, httpInterceptorProviders],
bootstrap: [AppComponent],
})
export class AppModule {}

View File

@@ -0,0 +1,14 @@
import { NgModule, Optional, SkipSelf } from '@angular/core';
import { CommonModule } from '@angular/common';
@NgModule({
declarations: [],
imports: [CommonModule],
})
export class CoreModule {
constructor(@Optional() @SkipSelf() core: CoreModule) {
if (core) {
throw new Error('You should import core module only in the root module');
}
}
}

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { AuthInterceptor } from './auth.interceptor';
describe('AuthInterceptor', () => {
beforeEach(() => TestBed.configureTestingModule({
providers: [
AuthInterceptor
]
}));
it('should be created', () => {
const interceptor: AuthInterceptor = TestBed.inject(AuthInterceptor);
expect(interceptor).toBeTruthy();
});
});

View File

@@ -0,0 +1,36 @@
import { Injectable } from '@angular/core';
import {
HttpRequest,
HttpHandler,
HttpEvent,
HttpInterceptor,
HttpErrorResponse,
} from '@angular/common/http';
import { Observable } from 'rxjs';
import { AuthService } from '../services/auth.service';
import { catchError } from 'rxjs/operators';
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
constructor(private auth: AuthService) {}
intercept(
request: HttpRequest<unknown>,
next: HttpHandler
): Observable<HttpEvent<unknown>> {
return next
.handle(
request.clone({ setHeaders: { 'X-API-KEY': this.auth.getApiKey() } })
)
.pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
// Unauthorized
const apiKey = window.prompt('API Key:') ?? '';
this.auth.setApiKey(apiKey);
}
throw error;
})
);
}
}

View File

@@ -0,0 +1,9 @@
/* "Barrel" of Http Interceptors */
import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { AuthInterceptor } from './auth.interceptor';
/** Http interceptor providers in outside-in order */
export const httpInterceptorProviders = [
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
];

View File

@@ -0,0 +1,16 @@
export interface AppInfo {
name: string;
version: string;
pid: number;
ppid: number;
create_time: number;
cwd: string;
exe: string;
cmdline: string[];
}
export interface appStatus {
cpu_percent: number;
memory_percent: number;
num_threads: number;
}

View File

@@ -0,0 +1,53 @@
import { RoomInfo, UserInfo } from 'src/app/tasks/shared/task.model';
export type Event =
| LiveBeganEvent
| LiveEndedEvent
| RoomChangeEvent
| SpaceNoEnoughEvent
| FilesAvailableEvent;
export interface LiveBeganEvent {
readonly type: 'LiveBeganEvent';
readonly data: {
readonly user_info: UserInfo;
readonly room_info: RoomInfo;
};
}
export interface LiveEndedEvent {
readonly type: 'LiveEndedEvent';
readonly data: {
readonly user_info: UserInfo;
readonly room_info: RoomInfo;
};
}
export interface RoomChangeEvent {
readonly type: 'RoomChangeEvent';
readonly data: {
readonly room_info: RoomInfo;
};
}
export interface SpaceNoEnoughEvent {
readonly type: 'SpaceNoEnoughEvent';
readonly data: {
path: string;
threshold: number;
usage: DiskUsage;
};
}
export interface FilesAvailableEvent {
readonly type: 'FilesAvailableEvent';
readonly data: {
files: string[];
};
}
export interface DiskUsage {
total: number;
used: number;
free: number;
}

View File

@@ -0,0 +1,4 @@
export enum ERRNO {
EACCES = 13,
ENOTDIR = 20,
}

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { AppService } from './app.service';
describe('AppService', () => {
let service: AppService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(AppService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,36 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from 'src/environments/environment';
import { AppInfo, appStatus } from '../models/app.models';
const apiUrl = environment.apiUrl;
@Injectable({
providedIn: 'root',
})
export class AppService {
constructor(private http: HttpClient) {}
getAppInfo(): Observable<AppInfo> {
const url = apiUrl + `/api/v1/app/info`;
return this.http.get<AppInfo>(url);
}
getAppStatus(): Observable<appStatus> {
const url = apiUrl + `/api/v1/app/status`;
return this.http.get<appStatus>(url);
}
restartApp(): Observable<undefined> {
const url = apiUrl + `/api/v1/app/restart`;
return this.http.post<undefined>(url, null);
}
exitApp(): Observable<undefined> {
const url = apiUrl + `/api/v1/app/exit`;
return this.http.post<undefined>(url, null);
}
}

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { AuthService } from './auth.service';
describe('AuthService', () => {
let service: AuthService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(AuthService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,19 @@
import { Injectable } from '@angular/core';
import { StorageService } from './storage.service';
const API_KEY_STORAGE_KEY = 'app-api-key';
@Injectable({
providedIn: 'root',
})
export class AuthService {
constructor(private storage: StorageService) {}
getApiKey(): string {
return this.storage.getData(API_KEY_STORAGE_KEY) ?? '';
}
setApiKey(value: string): void {
this.storage.setData(API_KEY_STORAGE_KEY, value);
}
}

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { EventService } from './event.service';
describe('EventService', () => {
let service: EventService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(EventService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,24 @@
import { Injectable } from '@angular/core';
import { webSocket, WebSocketSubject } from 'rxjs/webSocket';
import { environment } from 'src/environments/environment';
import { Event } from '../models/event.model';
const webSocketUrl = environment.webSocketUrl;
@Injectable({
providedIn: 'root',
})
export class EventService {
private eventSubject?: WebSocketSubject<Event>;
constructor() {}
get events() {
if (!this.eventSubject) {
this.eventSubject = webSocket(webSocketUrl + '/ws/v1/events');
}
return this.eventSubject;
}
}

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { ExceptionService } from './exception.service';
describe('ExceptionService', () => {
let service: ExceptionService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ExceptionService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,24 @@
import { Injectable } from '@angular/core';
import { webSocket, WebSocketSubject } from 'rxjs/webSocket';
import { environment } from 'src/environments/environment';
import { Event } from '../models/event.model';
const webSocketUrl = environment.webSocketUrl;
@Injectable({
providedIn: 'root'
})
export class ExceptionService {
private exceptionSubject?: WebSocketSubject<Event>;
constructor() {}
get exceptions() {
if (!this.exceptionSubject) {
this.exceptionSubject = webSocket(webSocketUrl + '/ws/v1/exceptions');
}
return this.exceptionSubject;
}
}

View File

@@ -0,0 +1,122 @@
// from https://medium.com/front-end-weekly/handling-scrolling-on-angular-router-transitions-e7652e57d964
import { NavigationEnd, NavigationStart } from "@angular/router";
/**
* Service that handles scrolling back to top if/when needed, depending on the configured strategies and route config.
* Can be configured through route data, by defining the "scrollBehavior" key and setting it to a valid {RouteScrollBehaviour} value
* References
* https://www.bennadel.com/blog/3534-restoring-and-resetting-the-scroll-position-using-the-navigationstart-event-in-angular-7-0-4.htm
* Potential for improvement from there: listen to scroll events on the DOM
* https://medium.com/angular-in-depth/reactive-scroll-position-restoration-with-rxjs-792577f842c
* https://medium.com/@samisalamiger/great-article-f4f642b134ab
*/
export interface IRouterScrollService {
/**
* Provide the DOM element corresponding to the main viewport.
* That viewport is the one that will be scrolled
*/
setCustomViewportToScroll(viewport: HTMLElement): void;
/**
* Disable scrolling the default viewport
*/
disableScrollDefaultViewport(): void;
/**
* Enable scrolling the default viewport (enabled by default)
*/
enableScrollDefaultViewPort(): void;
/**
* Add a strategy that applies before navigation for a partial route
* @param partialRoute the partial route to match
* @param behaviour the desired behavior
*/
addStrategyOnceBeforeNavigationForPartialRoute(partialRoute: string, behaviour: RouteScrollBehaviour): void;
/**
* Add a strategy for a partial route
* @param partialRoute the partial route to match
* @param behaviour the desired behavior
*/
addStrategyForPartialRoute(partialRoute: string, behaviour: RouteScrollBehaviour): void;
/**
* Remove a strategy for a partial route
* @param partialRoute the partial route to remove strategies for
*/
removeStrategyForPartialRoute(partialRoute: string): void;
}
/**
* Scroll position restore
*/
export interface ScrollPositionRestore {
/**
* Which event to match
*/
event: NavigationStart | NavigationEnd;
/**
* Used to keep track of the known positions.
* The key is the id of the entry (according to the route ids)
* The value is the scroll position. Any is used because there are different representations
*/
positions: Record<string, any>;
/**
* Trigger to react to
* Imperative: e.g., user clicked on a link
* Popstate: e.g., browser back button
* Hashchange: e.g., change in the URL fragment
*/
trigger: "imperative" | "popstate" | "hashchange" | undefined;
/**
* Id to restore
*/
idToRestore: number;
/**
* The route's data (if any defined)
*/
routeData?: CustomRouteData;
}
/**
* Defines a strategy to handle route scrolling.
*/
export interface RouteScrollStrategy {
/**
* Partial route path
*/
partialRoute: string;
/**
* Associated behavior
*/
behaviour: RouteScrollBehaviour;
/**
* Whether it should be applied before navigation (default is after)
*/
onceBeforeNavigation?: boolean;
}
/**
* Defines the possible route scroll behaviors
*/
export enum RouteScrollBehaviour {
KEEP_POSITION = "KEEP_POSITION",
GO_TO_TOP = "GO_TO_TOP",
}
/**
* Extends the default type of Angular to be more prescriptive
*/
export interface CustomRouteData {
/**
* Scroll behavior when navigating to this route
*/
scrollBehavior?: RouteScrollBehaviour;
}
/**
* Define a set of routes for the router.
* Usually one instance defined per module in the app.
*/
export type CustomRoutes = CustomRouteData[];

View File

@@ -0,0 +1,281 @@
// from https://medium.com/front-end-weekly/handling-scrolling-on-angular-router-transitions-e7652e57d964
import { ActivatedRoute, NavigationEnd, NavigationStart, Router } from "@angular/router";
import { Injectable, OnDestroy } from "@angular/core";
import { ViewportScroller } from "@angular/common";
import { filter, observeOn, scan } from "rxjs/operators";
import { asyncScheduler, Subscription } from "rxjs";
import {
IRouterScrollService,
RouteScrollBehaviour,
RouteScrollStrategy,
ScrollPositionRestore,
} from "./router-scroll.service.intf";
import { NGXLogger } from "ngx-logger";
import { environment } from 'src/environments/environment';
const componentName = "RouterScrollService";
const defaultViewportKey = `defaultViewport`;
const customViewportKey = `customViewport`;
@Injectable({
providedIn: 'root',
})
export class RouterScrollService implements IRouterScrollService, OnDestroy {
private readonly scrollPositionRestorationSubscription: Subscription | null;
/**
* Queue of strategies to add
*/
private addQueue: RouteScrollStrategy[] = [];
/**
* Queue of strategies to add for onBeforeNavigation
*/
private addBeforeNavigationQueue: RouteScrollStrategy[] = [];
/**
* Queue of strategies to remove
*/
private removeQueue: string[] = [];
/**
* Registered strategies
*/
private routeStrategies: RouteScrollStrategy[] = [];
/**
* Whether the default viewport should be scrolled if/when needed
*/
private scrollDefaultViewport = true;
/**
* Custom viewport to scroll if/when needed
*/
private customViewportToScroll: HTMLElement | null = null;
constructor(
private readonly router: Router,
private readonly activatedRoute: ActivatedRoute,
private readonly viewportScroller: ViewportScroller,
private readonly logger: NGXLogger,
) {
if (environment.traceRouterScrolling) {
this.logger.trace(`${componentName}:: constructor`);
}
if (environment.traceRouterScrolling) {
this.logger.trace(`${componentName}:: Subscribing to router events`);
}
const scrollPositionRestore$ = this.router.events.pipe(
filter((event: any) => event instanceof NavigationStart || event instanceof NavigationEnd),
// Accumulate the scroll positions
scan<NavigationEnd | NavigationStart, ScrollPositionRestore>((acc, event) => {
if (environment.traceRouterScrolling) {
this.logger.trace(`${componentName}:: Updating the known scroll positions`);
}
const positions: Record<string, any> = {
...acc.positions, // Keep the previously known positions
};
if (event instanceof NavigationStart && this.scrollDefaultViewport) {
if (environment.traceRouterScrolling) {
this.logger.trace(`${componentName}:: Storing the scroll position of the default viewport`);
}
positions[`${event.id}-${defaultViewportKey}`] = this.viewportScroller.getScrollPosition();
}
if (event instanceof NavigationStart && this.customViewportToScroll) {
if (environment.traceRouterScrolling) {
this.logger.trace(`${componentName}:: Storing the scroll position of the custom viewport`);
}
positions[`${event.id}-${customViewportKey}`] = this.customViewportToScroll.scrollTop;
}
const retVal: ScrollPositionRestore = {
event,
positions,
trigger: event instanceof NavigationStart ? event.navigationTrigger : acc.trigger,
idToRestore:
(event instanceof NavigationStart && event.restoredState && event.restoredState.navigationId + 1) ||
acc.idToRestore,
routeData: this.activatedRoute.firstChild?.routeConfig?.data,
};
return retVal;
}),
filter((scrollPositionRestore: ScrollPositionRestore) => !!scrollPositionRestore.trigger),
observeOn(asyncScheduler),
);
this.scrollPositionRestorationSubscription = scrollPositionRestore$.subscribe(
(scrollPositionRestore: ScrollPositionRestore) => {
const existingStrategy = this.routeStrategies.find(
(strategy) => scrollPositionRestore.event.url.indexOf(strategy.partialRoute) > -1,
);
const existingStrategyWithKeepScrollPositionBehavior =
(existingStrategy && existingStrategy.behaviour === RouteScrollBehaviour.KEEP_POSITION) || false;
const routeDataWithKeepScrollPositionBehavior =
(scrollPositionRestore.routeData &&
scrollPositionRestore.routeData.scrollBehavior &&
scrollPositionRestore.routeData.scrollBehavior === RouteScrollBehaviour.KEEP_POSITION) ||
false;
const shouldKeepScrollPosition =
existingStrategyWithKeepScrollPositionBehavior || routeDataWithKeepScrollPositionBehavior;
if (scrollPositionRestore.event instanceof NavigationEnd) {
this.processRemoveQueue(this.removeQueue);
// Was this an imperative navigation? This helps determine if we're moving forward through a routerLink, a back button click, etc
// Reference: https://www.bennadel.com/blog/3533-using-router-events-to-detect-back-and-forward-browser-navigation-in-angular-7-0-4.htm
const imperativeTrigger =
(scrollPositionRestore.trigger && "imperative" === scrollPositionRestore.trigger) || false;
// Should scroll to the top if
// no strategy or strategy with behavior different than keep position
// OR no route data or route data with behavior different than keep position
// OR imperative
// Reference: https://medium.com/javascript-everyday/angular-imperative-navigation-fbab18a25d8b
// Decide whether we should scroll back to top or not
const shouldScrollToTop = !shouldKeepScrollPosition || imperativeTrigger;
if (environment.traceRouterScrolling) {
this.logger.trace(
`${componentName}:: Existing strategy with keep position behavior? `,
existingStrategyWithKeepScrollPositionBehavior,
);
this.logger.trace(
`${componentName}:: Route data with keep position behavior? `,
routeDataWithKeepScrollPositionBehavior,
);
this.logger.trace(`${componentName}:: Imperative trigger? `, imperativeTrigger);
this.logger.debug(`${componentName}:: Should scroll? `, shouldScrollToTop);
}
if (shouldScrollToTop) {
if (this.scrollDefaultViewport) {
if (environment.traceRouterScrolling) {
this.logger.debug(`${componentName}:: Scrolling the default viewport`);
}
this.viewportScroller.scrollToPosition([0, 0]);
}
if (this.customViewportToScroll) {
if (environment.traceRouterScrolling) {
this.logger.debug(`${componentName}:: Scrolling a custom viewport: `, this.customViewportToScroll);
}
this.customViewportToScroll.scrollTop = 0;
}
} else {
if (environment.traceRouterScrolling) {
this.logger.debug(`${componentName}:: Not scrolling`);
}
if (this.scrollDefaultViewport) {
this.viewportScroller.scrollToPosition(
scrollPositionRestore.positions[`${scrollPositionRestore.idToRestore}-${defaultViewportKey}`],
);
}
if (this.customViewportToScroll) {
this.customViewportToScroll.scrollTop =
scrollPositionRestore.positions[`${scrollPositionRestore.idToRestore}-${customViewportKey}`];
}
}
this.processRemoveQueue(
this.addBeforeNavigationQueue.map((strategy) => strategy.partialRoute),
true,
);
this.processAddQueue(this.addQueue);
this.addQueue = [];
this.removeQueue = [];
this.addBeforeNavigationQueue = [];
} else {
this.processAddQueue(this.addBeforeNavigationQueue);
}
},
);
}
addStrategyOnceBeforeNavigationForPartialRoute(partialRoute: string, behaviour: RouteScrollBehaviour): void {
if (environment.traceRouterScrolling) {
this.logger.trace(
`${componentName}:: Adding a strategy once for before navigation towards [${partialRoute}]: `,
behaviour,
);
}
this.addBeforeNavigationQueue.push({
partialRoute: partialRoute,
behaviour: behaviour,
onceBeforeNavigation: true,
});
}
addStrategyForPartialRoute(partialRoute: string, behaviour: RouteScrollBehaviour): void {
if (environment.traceRouterScrolling) {
this.logger.trace(`${componentName}:: Adding a strategy for partial route: [${partialRoute}]`, behaviour);
}
this.addQueue.push({ partialRoute: partialRoute, behaviour: behaviour });
}
removeStrategyForPartialRoute(partialRoute: string): void {
if (environment.traceRouterScrolling) {
this.logger.trace(`${componentName}:: Removing strategory for: [${partialRoute}]: `);
}
this.removeQueue.push(partialRoute);
}
setCustomViewportToScroll(viewport: HTMLElement): void {
if (environment.traceRouterScrolling) {
this.logger.trace(`${componentName}:: Setting a custom viewport to scroll: `, viewport);
}
this.customViewportToScroll = viewport;
}
disableScrollDefaultViewport(): void {
if (environment.traceRouterScrolling) {
this.logger.trace(`${componentName}:: Disabling scrolling the default viewport`);
}
this.scrollDefaultViewport = false;
}
enableScrollDefaultViewPort(): void {
if (environment.traceRouterScrolling) {
this.logger.trace(`${componentName}:: Enabling scrolling the default viewport`);
}
this.scrollDefaultViewport = true;
}
processAddQueue(queue: any) {
for (const partialRouteToAdd of queue) {
const pos = this.routeStrategyPosition(partialRouteToAdd.partialRoute);
if (pos === -1) {
this.routeStrategies.push(partialRouteToAdd);
}
}
}
processRemoveQueue(queue: any, removeOnceBeforeNavigation = false) {
for (const partialRouteToRemove of queue) {
const pos = this.routeStrategyPosition(partialRouteToRemove);
if (!removeOnceBeforeNavigation && pos > -1 && this.routeStrategies[pos].onceBeforeNavigation) {
continue;
}
if (pos > -1) {
this.routeStrategies.splice(pos, 1);
}
}
}
routeStrategyPosition(partialRoute: string) {
return this.routeStrategies.map((strategy) => strategy.partialRoute).indexOf(partialRoute);
}
ngOnDestroy(): void {
if (environment.traceRouterScrolling) {
this.logger.trace(`${componentName}:: ngOnDestroy`);
}
if (this.scrollPositionRestorationSubscription) {
this.scrollPositionRestorationSubscription.unsubscribe();
}
}
}

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { StorageService } from './storage.service';
describe('StorageService', () => {
let service: StorageService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(StorageService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,30 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class StorageService {
private impl = localStorage;
constructor() {}
hasData(key: string): boolean {
return this.getData(key) !== null;
}
getData(key: string): string | null {
return this.impl.getItem(key);
}
setData(key: string, value: string): void {
this.impl.setItem(key, value);
}
removeData(key: string): void {
this.impl.removeItem(key);
}
clearData(): void {
this.impl.clear();
}
}

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { UpdateService } from './update.service';
describe('UpdateService', () => {
let service: UpdateService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(UpdateService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,20 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from 'src/environments/environment';
const apiUrl = environment.apiUrl;
@Injectable({
providedIn: 'root',
})
export class UpdateService {
constructor(private http: HttpClient) {}
getLatestVerisonString(): Observable<string> {
const url = apiUrl + `/api/v1/update/version/latest`;
return this.http.get<string>(url);
}
}

View File

@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { ValidationService } from './validation.service';
describe('ValidationService', () => {
let service: ValidationService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ValidationService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});

View File

@@ -0,0 +1,21 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from 'src/environments/environment';
import { ResponseMessage } from 'src/app/shared/api.models';
const apiUrl = environment.apiUrl;
@Injectable({
providedIn: 'root',
})
export class ValidationService {
constructor(private http: HttpClient) {}
validateDir(path: string): Observable<ResponseMessage> {
const url = apiUrl + `/api/v1/validation/dir`;
return this.http.post<ResponseMessage>(url, { path });
}
}

View File

@@ -0,0 +1,21 @@
import { NgModule } from '@angular/core';
import { NZ_ICONS, NzIconModule } from 'ng-zorro-antd/icon';
import {
MenuFoldOutline,
MenuUnfoldOutline,
FormOutline,
DashboardOutline
} from '@ant-design/icons-angular/icons';
const icons = [MenuFoldOutline, MenuUnfoldOutline, DashboardOutline, FormOutline];
@NgModule({
imports: [NzIconModule],
exports: [NzIconModule],
providers: [
{ provide: NZ_ICONS, useValue: icons }
]
})
export class IconsProviderModule {
}

View File

@@ -0,0 +1,6 @@
<div class="content">
<img src="assets/images/bili-404.png" all="肥肠抱歉,你要找的页面不见了" />
<div class="btn-wrapper">
<a class="goback-btn" href="/">返回首页</a>
</div>
</div>

View File

@@ -0,0 +1,34 @@
@use '../shared/styles/layout';
.content {
@include layout.full-width-height;
@include layout.center-content;
flex-direction: column;
img {
max-width: 980px;
}
.btn-wrapper {
@include layout.center-content;
.goback-btn {
display: inline-block;
padding: 0 20px;
border-radius: 4px;
font-size: 16px;
line-height: 40px;
text-align: center;
vertical-align: middle;
color: #fff;
background: #00a1d6;
transition: 0.3s;
cursor: pointer;
&:hover {
background: #00b5e5;
}
}
}
}

View File

@@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PageNotFoundComponent } from './page-not-found.component';
describe('PageNotFoundComponent', () => {
let component: PageNotFoundComponent;
let fixture: ComponentFixture<PageNotFoundComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ PageNotFoundComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(PageNotFoundComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,16 @@
import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-page-not-found',
templateUrl: './page-not-found.component.html',
styleUrls: ['./page-not-found.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class PageNotFoundComponent implements OnInit {
constructor() { }
ngOnInit(): void {
}
}

View File

@@ -0,0 +1,17 @@
<form nz-form [formGroup]="settingsForm">
<nz-form-item class="setting-item" appSwitchActionable>
<nz-form-label
class="setting-label"
nzNoColon
nzTooltipTitle="发送者: 弹幕内容"
>弹幕加用户名</nz-form-label
>
<nz-form-control
class="setting-control switch"
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="syncStatus.danmuUname ? danmuUnameControl : 'warning'"
>
<nz-switch formControlName="danmuUname"></nz-switch>
</nz-form-control>
</nz-form-item>
</form>

View File

@@ -0,0 +1 @@
@use '../shared/styles/setting';

View File

@@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DanmakuSettingsComponent } from './danmaku-settings.component';
describe('DanmakuSettingsComponent', () => {
let component: DanmakuSettingsComponent;
let fixture: ComponentFixture<DanmakuSettingsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ DanmakuSettingsComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(DanmakuSettingsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,66 @@
import {
Component,
OnInit,
ChangeDetectionStrategy,
Input,
OnChanges,
ChangeDetectorRef,
} from '@angular/core';
import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
import { Observable } from 'rxjs';
import mapValues from 'lodash-es/mapValues';
import { DanmakuSettings } from '../shared/setting.model';
import {
SettingsSyncService,
SyncStatus,
calcSyncStatus,
} from '../shared/services/settings-sync.service';
import { SYNC_FAILED_WARNING_TIP } from '../shared/constants/form';
@Component({
selector: 'app-danmaku-settings',
templateUrl: './danmaku-settings.component.html',
styleUrls: ['./danmaku-settings.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DanmakuSettingsComponent implements OnInit, OnChanges {
@Input() settings!: DanmakuSettings;
syncStatus!: SyncStatus<DanmakuSettings>;
readonly settingsForm: FormGroup;
readonly syncFailedWarningTip = SYNC_FAILED_WARNING_TIP;
constructor(
formBuilder: FormBuilder,
private changeDetector: ChangeDetectorRef,
private settingsSyncService: SettingsSyncService
) {
this.settingsForm = formBuilder.group({
danmuUname: [''],
});
}
get danmuUnameControl() {
return this.settingsForm.get('danmuUname') as FormControl;
}
ngOnChanges(): void {
this.syncStatus = mapValues(this.settings, () => true);
this.settingsForm.setValue(this.settings);
}
ngOnInit(): void {
this.settingsSyncService
.syncSettings(
'danmaku',
this.settings,
this.settingsForm.valueChanges as Observable<DanmakuSettings>
)
.subscribe((detail) => {
this.syncStatus = { ...this.syncStatus, ...calcSyncStatus(detail) };
this.changeDetector.markForCheck();
});
}
}

View File

@@ -0,0 +1,49 @@
<form nz-form [formGroup]="settingsForm">
<nz-form-item class="setting-item">
<nz-form-label class="setting-label" nzNoColon
>硬盘空间检测间隔</nz-form-label
>
<nz-form-control
class="setting-control select"
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="
syncStatus.checkInterval ? checkIntervalControl : 'warning'
"
>
<nz-select formControlName="checkInterval" [nzOptions]="intervalOptions">
</nz-select>
</nz-form-control>
</nz-form-item>
<nz-form-item class="setting-item">
<nz-form-label class="setting-label" nzNoColon
>硬盘空间检测阈值</nz-form-label
>
<nz-form-control
class="setting-control select"
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="
syncStatus.spaceThreshold ? spaceThresholdControl : 'warning'
"
>
<nz-select
formControlName="spaceThreshold"
[nzOptions]="thresholdOptions"
>
</nz-select>
</nz-form-control>
</nz-form-item>
<nz-form-item class="setting-item" appSwitchActionable>
<nz-form-label class="setting-label" nzNoColon
>空间不足删除旧录播文件</nz-form-label
>
<nz-form-control
class="setting-control switch"
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="
syncStatus.recycleRecords ? recycleRecordsControl : 'warning'
"
>
<nz-switch formControlName="recycleRecords"></nz-switch>
</nz-form-control>
</nz-form-item>
</form>

View File

@@ -0,0 +1 @@
@use '../shared/styles/setting';

View File

@@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DiskSpaceSettingsComponent } from './disk-space-settings.component';
describe('DiskSpaceSettingsComponent', () => {
let component: DiskSpaceSettingsComponent;
let fixture: ComponentFixture<DiskSpaceSettingsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ DiskSpaceSettingsComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(DiskSpaceSettingsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,90 @@
import {
Component,
OnInit,
ChangeDetectionStrategy,
Input,
OnChanges,
ChangeDetectorRef,
} from '@angular/core';
import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
import { Observable } from 'rxjs';
import mapValues from 'lodash-es/mapValues';
import { SpaceSettings } from '../shared/setting.model';
import {
SettingsSyncService,
SyncStatus,
calcSyncStatus,
} from '../shared/services/settings-sync.service';
import { SYNC_FAILED_WARNING_TIP } from '../shared/constants/form';
@Component({
selector: 'app-disk-space-settings',
templateUrl: './disk-space-settings.component.html',
styleUrls: ['./disk-space-settings.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DiskSpaceSettingsComponent implements OnInit, OnChanges {
@Input() settings!: SpaceSettings;
syncStatus!: SyncStatus<SpaceSettings>;
readonly settingsForm: FormGroup;
readonly syncFailedWarningTip = SYNC_FAILED_WARNING_TIP;
readonly intervalOptions = [
{ label: '1 分钟', value: 60 },
{ label: '3 分钟', value: 180 },
{ label: '5 分钟', value: 300 },
{ label: '10 分钟', value: 600 },
];
readonly thresholdOptions = [
{ label: '1 GB', value: 1024 ** 3 },
{ label: '3 GB', value: 1024 ** 3 * 3 },
{ label: '5 GB', value: 1024 ** 3 * 5 },
{ label: '10 GB', value: 1024 ** 3 * 10 },
];
constructor(
formBuilder: FormBuilder,
private changeDetector: ChangeDetectorRef,
private settingsSyncService: SettingsSyncService
) {
this.settingsForm = formBuilder.group({
recycleRecords: [''],
checkInterval: [''],
spaceThreshold: [''],
});
}
get recycleRecordsControl() {
return this.settingsForm.get('recycleRecords') as FormControl;
}
get checkIntervalControl() {
return this.settingsForm.get('checkInterval') as FormControl;
}
get spaceThresholdControl() {
return this.settingsForm.get('spaceThreshold') as FormControl;
}
ngOnChanges(): void {
this.syncStatus = mapValues(this.settings, () => true);
this.settingsForm.setValue(this.settings);
}
ngOnInit(): void {
this.settingsSyncService
.syncSettings(
'space',
this.settings,
this.settingsForm.valueChanges as Observable<SpaceSettings>
)
.subscribe((detail) => {
this.syncStatus = { ...this.syncStatus, ...calcSyncStatus(detail) };
this.changeDetector.markForCheck();
});
}
}

View File

@@ -0,0 +1,31 @@
<nz-modal
nzTitle="修改 Cookie"
nzOkDanger
nzCentered
[(nzVisible)]="visible"
[nzOkDisabled]="control.invalid || control.value.trim() === value"
(nzOnOk)="handleConfirm()"
(nzOnCancel)="handleCancel()"
>
<ng-container *nzModalContent>
<form nz-form [formGroup]="settingsForm">
<nz-form-item>
<nz-form-control
[nzWarningTip]="warningTip"
[nzValidateStatus]="
control.valid && control.value.trim() !== value
? 'warning'
: control
"
>
<textarea
[rows]="5"
wrap="soft"
nz-input
formControlName="cookie"
></textarea>
</nz-form-control>
</nz-form-item>
</form>
</ng-container>
</nz-modal>

View File

@@ -0,0 +1 @@
@use '../../shared/styles/setting';

View File

@@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CookieEditDialogComponent } from './cookie-edit-dialog.component';
describe('CookieEditDialogComponent', () => {
let component: CookieEditDialogComponent;
let fixture: ComponentFixture<CookieEditDialogComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ CookieEditDialogComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(CookieEditDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,75 @@
import {
Component,
ChangeDetectionStrategy,
Input,
Output,
ChangeDetectorRef,
EventEmitter,
OnChanges,
} from '@angular/core';
import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
@Component({
selector: 'app-cookie-edit-dialog',
templateUrl: './cookie-edit-dialog.component.html',
styleUrls: ['./cookie-edit-dialog.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CookieEditDialogComponent implements OnChanges {
@Input() value = '';
@Input() visible = false;
@Output() visibleChange = new EventEmitter<boolean>();
@Output() cancel = new EventEmitter<undefined>();
@Output() confirm = new EventEmitter<string>();
readonly settingsForm: FormGroup;
readonly warningTip =
'全部任务都需重启弹幕客户端才能生效,正在录制的任务可能会丢失弹幕!';
constructor(
formBuilder: FormBuilder,
private changeDetector: ChangeDetectorRef
) {
this.settingsForm = formBuilder.group({
cookie: [''],
});
}
get control() {
return this.settingsForm.get('cookie') as FormControl;
}
ngOnChanges(): void {
this.setValue();
}
open(): void {
this.setValue();
this.setVisible(true);
}
close(): void {
this.setVisible(false);
}
setVisible(visible: boolean): void {
this.visible = visible;
this.visibleChange.emit(visible);
this.changeDetector.markForCheck();
}
setValue(): void {
this.control.setValue(this.value);
this.changeDetector.markForCheck();
}
handleCancel(): void {
this.cancel.emit();
this.close();
}
handleConfirm(): void {
this.confirm.emit(this.control.value.trim());
this.close();
}
}

View File

@@ -0,0 +1,40 @@
<form nz-form [formGroup]="settingsForm">
<nz-form-item
class="setting-item actionable"
(click)="userAgentEditDialog.open()"
>
<nz-form-label class="setting-label">User Agent</nz-form-label>
<nz-form-control
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="syncStatus.userAgent ? userAgentControl : 'warning'"
>
<nz-form-text class="setting-value"
>{{ userAgentControl.value }}
</nz-form-text>
<app-user-agent-edit-dialog
#userAgentEditDialog
[value]="userAgentControl.value"
(confirm)="userAgentControl.setValue($event)"
></app-user-agent-edit-dialog>
</nz-form-control>
</nz-form-item>
<nz-form-item
class="setting-item actionable"
(click)="cookieEditDialog.open()"
>
<nz-form-label class="setting-label">Cookie</nz-form-label>
<nz-form-control
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="syncStatus.cookie ? cookieControl : 'warning'"
>
<nz-form-text class="setting-value"
>{{ cookieControl.value }}
</nz-form-text>
<app-cookie-edit-dialog
#cookieEditDialog
[value]="cookieControl.value"
(confirm)="cookieControl.setValue($event)"
></app-cookie-edit-dialog>
</nz-form-control>
</nz-form-item>
</form>

View File

@@ -0,0 +1 @@
@use '../shared/styles/setting';

View File

@@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HeaderSettingsComponent } from './header-settings.component';
describe('HeaderSettingsComponent', () => {
let component: HeaderSettingsComponent;
let fixture: ComponentFixture<HeaderSettingsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ HeaderSettingsComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(HeaderSettingsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,76 @@
import {
Component,
OnInit,
ChangeDetectionStrategy,
Input,
OnChanges,
ChangeDetectorRef,
} from '@angular/core';
import {
FormBuilder,
FormControl,
FormGroup,
Validators,
} from '@angular/forms';
import { Observable } from 'rxjs';
import mapValues from 'lodash-es/mapValues';
import { HeaderSettings } from '../shared/setting.model';
import {
SettingsSyncService,
SyncStatus,
calcSyncStatus,
} from '../shared/services/settings-sync.service';
import { SYNC_FAILED_WARNING_TIP } from '../shared/constants/form';
@Component({
selector: 'app-header-settings',
templateUrl: './header-settings.component.html',
styleUrls: ['./header-settings.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HeaderSettingsComponent implements OnInit, OnChanges {
@Input() settings!: HeaderSettings;
syncStatus!: SyncStatus<HeaderSettings>;
readonly settingsForm: FormGroup;
readonly syncFailedWarningTip = SYNC_FAILED_WARNING_TIP;
constructor(
formBuilder: FormBuilder,
private changeDetector: ChangeDetectorRef,
private settingsSyncService: SettingsSyncService
) {
this.settingsForm = formBuilder.group({
userAgent: ['', [Validators.required]],
cookie: [''],
});
}
get userAgentControl() {
return this.settingsForm.get('userAgent') as FormControl;
}
get cookieControl() {
return this.settingsForm.get('cookie') as FormControl;
}
ngOnChanges(): void {
this.syncStatus = mapValues(this.settings, () => true);
this.settingsForm.setValue(this.settings);
}
ngOnInit(): void {
this.settingsSyncService
.syncSettings(
'header',
this.settings,
this.settingsForm.valueChanges as Observable<HeaderSettings>
)
.subscribe((detail) => {
this.syncStatus = { ...this.syncStatus, ...calcSyncStatus(detail) };
this.changeDetector.markForCheck();
});
}
}

View File

@@ -0,0 +1,37 @@
<nz-modal
nzTitle="修改 User Agent"
nzOkDanger
nzCentered
[(nzVisible)]="visible"
[nzOkDisabled]="control.invalid || control.value.trim() === value"
(nzOnOk)="handleConfirm()"
(nzOnCancel)="handleCancel()"
>
<ng-container *nzModalContent>
<form nz-form [formGroup]="settingsForm">
<nz-form-item>
<nz-form-control
[nzWarningTip]="warningTip"
[nzValidateStatus]="
control.valid && control.value.trim() !== value
? 'warning'
: control
"
[nzErrorTip]="errorTip"
>
<textarea
[rows]="3"
required
nz-input
formControlName="userAgent"
></textarea>
<ng-template #errorTip let-control>
<ng-container *ngIf="control.hasError('required')">
请输入 User Agent
</ng-container>
</ng-template>
</nz-form-control>
</nz-form-item>
</form>
</ng-container>
</nz-modal>

View File

@@ -0,0 +1 @@
@use '../../shared/styles/setting';

View File

@@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { UserAgentEditDialogComponent } from './user-agent-edit-dialog.component';
describe('UserAgentEditDialogComponent', () => {
let component: UserAgentEditDialogComponent;
let fixture: ComponentFixture<UserAgentEditDialogComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ UserAgentEditDialogComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(UserAgentEditDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,80 @@
import {
Component,
ChangeDetectionStrategy,
Input,
Output,
ChangeDetectorRef,
EventEmitter,
OnChanges,
} from '@angular/core';
import {
FormBuilder,
FormControl,
FormGroup,
Validators,
} from '@angular/forms';
@Component({
selector: 'app-user-agent-edit-dialog',
templateUrl: './user-agent-edit-dialog.component.html',
styleUrls: ['./user-agent-edit-dialog.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserAgentEditDialogComponent implements OnChanges {
@Input() value = '';
@Input() visible = false;
@Output() visibleChange = new EventEmitter<boolean>();
@Output() cancel = new EventEmitter<undefined>();
@Output() confirm = new EventEmitter<string>();
readonly settingsForm: FormGroup;
readonly warningTip =
'全部任务都需重启弹幕客户端才能生效,正在录制的任务可能会丢失弹幕!';
constructor(
formBuilder: FormBuilder,
private changeDetector: ChangeDetectorRef
) {
this.settingsForm = formBuilder.group({
userAgent: ['', [Validators.required]],
});
}
get control() {
return this.settingsForm.get('userAgent') as FormControl;
}
ngOnChanges(): void {
this.setValue();
}
open(): void {
this.setValue();
this.setVisible(true);
}
close(): void {
this.setVisible(false);
}
setVisible(visible: boolean): void {
this.visible = visible;
this.visibleChange.emit(visible);
this.changeDetector.markForCheck();
}
setValue(): void {
this.control.setValue(this.value);
this.changeDetector.markForCheck();
}
handleCancel(): void {
this.cancel.emit();
this.close();
}
handleConfirm(): void {
this.confirm.emit(this.control.value.trim());
this.close();
}
}

View File

@@ -0,0 +1,48 @@
<form nz-form [formGroup]="settingsForm">
<nz-form-item class="setting-item" appSwitchActionable>
<nz-form-label class="setting-label" nzNoColon
>终端日志输出级别</nz-form-label
>
<nz-form-control
class="setting-control select"
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="
syncStatus.consoleLogLevel ? consoleLogLevelControl : 'warning'
"
>
<nz-select
formControlName="consoleLogLevel"
[nzOptions]="logLevelOptions"
>
</nz-select>
</nz-form-control>
</nz-form-item>
<nz-form-item class="setting-item">
<nz-form-label class="setting-label" nzNoColon
>日志文件分割大小</nz-form-label
>
<nz-form-control
class="setting-control select"
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="syncStatus.maxBytes ? maxBytesControl : 'warning'"
>
<nz-select formControlName="maxBytes" [nzOptions]="maxBytesOptions">
</nz-select>
</nz-form-control>
</nz-form-item>
<nz-form-item class="setting-item">
<nz-form-label class="setting-label" nzNoColon
>日志文件备份数量</nz-form-label
>
<nz-form-control
class="setting-control select"
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="
syncStatus.backupCount ? backupCountControl : 'warning'
"
>
<nz-select formControlName="backupCount" [nzOptions]="backupOptions">
</nz-select>
</nz-form-control>
</nz-form-item>
</form>

View File

@@ -0,0 +1 @@
@use '../shared/styles/setting';

View File

@@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { LoggingSettingsComponent } from './logging-settings.component';
describe('LoggingSettingsComponent', () => {
let component: LoggingSettingsComponent;
let fixture: ComponentFixture<LoggingSettingsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ LoggingSettingsComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(LoggingSettingsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,96 @@
import {
Component,
OnInit,
ChangeDetectionStrategy,
Input,
OnChanges,
ChangeDetectorRef,
} from '@angular/core';
import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
import { Observable } from 'rxjs';
import mapValues from 'lodash-es/mapValues';
import range from 'lodash-es/range';
import { LoggingSettings } from '../shared/setting.model';
import {
SettingsSyncService,
SyncStatus,
calcSyncStatus,
} from '../shared/services/settings-sync.service';
import { SYNC_FAILED_WARNING_TIP } from '../shared/constants/form';
@Component({
selector: 'app-logging-settings',
templateUrl: './logging-settings.component.html',
styleUrls: ['./logging-settings.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LoggingSettingsComponent implements OnInit, OnChanges {
@Input() settings!: LoggingSettings;
syncStatus!: SyncStatus<LoggingSettings>;
readonly settingsForm: FormGroup;
readonly syncFailedWarningTip = SYNC_FAILED_WARNING_TIP;
readonly logLevelOptions = [
{ label: 'VERBOSE', value: 'NOTSET' },
{ label: 'DEBUG', value: 'DEBUG' },
{ label: 'INFO', value: 'INFO' },
{ label: 'WARNING', value: 'WARNING' },
{ label: 'ERROR', value: 'ERROR' },
{ label: 'CRITICAL', value: 'CRITICAL' },
];
readonly maxBytesOptions = range(1, 11).map((i) => ({
label: `${i} MB`,
value: 1024 ** 2 * i,
}));
readonly backupOptions = range(1, 31).map((i) => ({
label: i.toString(),
value: i,
}));
constructor(
formBuilder: FormBuilder,
private changeDetector: ChangeDetectorRef,
private settingsSyncService: SettingsSyncService
) {
this.settingsForm = formBuilder.group({
consoleLogLevel: [''],
maxBytes: [''],
backupCount: [''],
});
}
get consoleLogLevelControl() {
return this.settingsForm.get('consoleLogLevel') as FormControl;
}
get maxBytesControl() {
return this.settingsForm.get('maxBytes') as FormControl;
}
get backupCountControl() {
return this.settingsForm.get('backupCount') as FormControl;
}
ngOnChanges(): void {
this.syncStatus = mapValues(this.settings, () => true);
this.settingsForm.setValue(this.settings);
}
ngOnInit(): void {
this.settingsSyncService
.syncSettings(
'logging',
this.settings,
this.settingsForm.valueChanges as Observable<LoggingSettings>
)
.subscribe((detail) => {
this.syncStatus = { ...this.syncStatus, ...calcSyncStatus(detail) };
this.changeDetector.markForCheck();
});
}
}

View File

@@ -0,0 +1,21 @@
<app-sub-page pageTitle="邮件通知">
<ng-template appSubPageContent>
<app-page-section>
<app-notifier-settings
[settings]="notifierSettings"
keyOfSettings="emailNotification"
></app-notifier-settings>
</app-page-section>
<app-page-section name="邮箱">
<app-email-settings [settings]="emailSettings"></app-email-settings>
</app-page-section>
<app-page-section name="事件">
<app-event-settings
[settings]="notificationSettings"
keyOfSettings="emailNotification"
></app-event-settings>
</app-page-section>
</ng-template>
</app-sub-page>

View File

@@ -0,0 +1,2 @@
@use '../../shared/styles/setting';

View File

@@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { EmailNotificationSettingsComponent } from './email-notification-settings.component';
describe('EmailNotificationSettingsComponent', () => {
let component: EmailNotificationSettingsComponent;
let fixture: ComponentFixture<EmailNotificationSettingsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ EmailNotificationSettingsComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(EmailNotificationSettingsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,46 @@
import {
Component,
OnInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import pick from 'lodash-es/pick';
import {
EmailNotificationSettings,
EmailSettings,
NotifierSettings,
NotificationSettings,
KEYS_OF_EMAIL_SETTINGS,
KEYS_OF_NOTIFIER_SETTINGS,
KEYS_OF_NOTIFICATION_SETTINGS,
} from '../../shared/setting.model';
@Component({
selector: 'app-email-notification-settings',
templateUrl: './email-notification-settings.component.html',
styleUrls: ['./email-notification-settings.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EmailNotificationSettingsComponent implements OnInit {
emailSettings!: EmailSettings;
notifierSettings!: NotifierSettings;
notificationSettings!: NotificationSettings;
constructor(
private changeDetector: ChangeDetectorRef,
private route: ActivatedRoute
) {}
ngOnInit(): void {
this.route.data.subscribe((data) => {
const settings = data.settings as EmailNotificationSettings;
this.emailSettings = pick(settings, KEYS_OF_EMAIL_SETTINGS);
this.notifierSettings = pick(settings, KEYS_OF_NOTIFIER_SETTINGS);
this.notificationSettings = pick(settings, KEYS_OF_NOTIFICATION_SETTINGS);
this.changeDetector.markForCheck();
});
}
}

View File

@@ -0,0 +1,159 @@
<form nz-form [formGroup]="settingsForm">
<nz-form-item class="setting-item">
<nz-form-label class="setting-label" nzFor="srcAddr" nzNoColon nzRequired
>发送邮箱</nz-form-label
>
<nz-form-control
class="setting-control input"
nzHasFeedback
[nzErrorTip]="emailErrorTip"
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="
srcAddrControl.valid && !syncStatus.srcAddr ? 'warning' : srcAddrControl
"
>
<input
id="srcAddr"
type="email"
placeholder="发送通知的邮箱地址"
required
nz-input
formControlName="srcAddr"
/>
<ng-template #emailErrorTip let-control>
<ng-container *ngIf="control.hasError('required')">
请输入邮箱地址!
</ng-container>
<ng-container *ngIf="control.hasError('email')">
邮箱地址无效!
</ng-container>
</ng-template>
</nz-form-control>
</nz-form-item>
<nz-form-item class="setting-item">
<nz-form-label class="setting-label" nzFor="authCode" nzNoColon nzRequired
>授权码</nz-form-label
>
<nz-form-control
class="setting-control input"
nzHasFeedback
[nzErrorTip]="authCodeErrorTip"
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="
authCodeControl.valid && !syncStatus.authCode
? 'warning'
: authCodeControl
"
>
<input
id="authCode"
type="text"
placeholder="发送邮箱的 SMTP 授权码"
required
nz-input
formControlName="authCode"
/>
<ng-template #authCodeErrorTip let-control>
<ng-container *ngIf="control.hasError('required')">
请输入授权码!
</ng-container>
</ng-template>
</nz-form-control>
</nz-form-item>
<nz-form-item class="setting-item">
<nz-form-label class="setting-label" nzFor="smtpHost" nzNoColon nzRequired
>SMTP 主机</nz-form-label
>
<nz-form-control
class="setting-control input"
nzHasFeedback
[nzErrorTip]="smtpHostErrorTip"
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="
smtpHostControl.valid && !syncStatus.smtpHost
? 'warning'
: smtpHostControl
"
>
<input
id="smtpHost"
type="text"
placeholder="发送邮箱的 SMTP 主机例如smtp.163.com 。"
required
nz-input
formControlName="smtpHost"
/>
<ng-template #smtpHostErrorTip let-control>
<ng-container *ngIf="control.hasError('required')">
请输入 SMTP 主机!
</ng-container>
</ng-template>
</nz-form-control>
</nz-form-item>
<nz-form-item class="setting-item">
<nz-form-label class="setting-label" nzFor="smtpPort" nzNoColon nzRequired
>SMTP 端口</nz-form-label
>
<nz-form-control
class="setting-control input"
nzHasFeedback
[nzErrorTip]="smtpPortErrorTip"
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="
smtpPortControl.valid && !syncStatus.smtpPort
? 'warning'
: smtpPortControl
"
>
<input
id="smtpPort"
type="text"
pattern="\d+"
placeholder="发送邮箱的 SMTP 主机端口,通常为 465 。"
required
nz-input
formControlName="smtpPort"
/>
<ng-template #smtpPortErrorTip let-control>
<ng-container *ngIf="control.hasError('required')">
请输入 SMTP 端口!
</ng-container>
<ng-container *ngIf="control.hasError('pattern')">
SMTP 端口无效!
</ng-container>
</ng-template>
</nz-form-control>
</nz-form-item>
<nz-form-item class="setting-item">
<nz-form-label class="setting-label" nzFor="dstAddr" nzNoColon nzRequired
>接收邮箱</nz-form-label
>
<nz-form-control
class="setting-control input"
nzHasFeedback
[nzErrorTip]="emailErrorTip"
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="
dstAddrControl.valid && !syncStatus.dstAddr ? 'warning' : dstAddrControl
"
>
<input
id="dstAddr"
type="email"
placeholder="接收通知的邮箱地址,可以和发送邮箱相同实现自发自收。"
required
nz-input
formControlName="dstAddr"
/>
<ng-template #emailErrorTip let-control>
<ng-container *ngIf="control.hasError('required')">
请输入邮箱地址!
</ng-container>
<ng-container *ngIf="control.hasError('email')">
邮箱地址无效!
</ng-container>
</ng-template>
</nz-form-control>
</nz-form-item>
</form>

View File

@@ -0,0 +1,6 @@
@use '../../../shared/styles/setting';
.setting-label {
max-width: 6em !important;
width: 6em !important;
}

View File

@@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { EmailSettingsComponent } from './email-settings.component';
describe('EmailSettingsComponent', () => {
let component: EmailSettingsComponent;
let fixture: ComponentFixture<EmailSettingsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ EmailSettingsComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(EmailSettingsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,105 @@
import {
Component,
OnInit,
ChangeDetectionStrategy,
Input,
OnChanges,
ChangeDetectorRef,
} from '@angular/core';
import {
FormBuilder,
FormControl,
FormGroup,
Validators,
} from '@angular/forms';
import { map } from 'rxjs/operators';
import transform from 'lodash-es/transform';
import mapValues from 'lodash-es/mapValues';
import { EmailSettings } from '../../../shared/setting.model';
import { filterValueChanges } from '../../../shared/rx-operators';
import {
SettingsSyncService,
SyncStatus,
calcSyncStatus,
} from '../../../shared/services/settings-sync.service';
import { SYNC_FAILED_WARNING_TIP } from 'src/app/settings/shared/constants/form';
@Component({
selector: 'app-email-settings',
templateUrl: './email-settings.component.html',
styleUrls: ['./email-settings.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class EmailSettingsComponent implements OnInit, OnChanges {
@Input() settings!: EmailSettings;
syncStatus!: SyncStatus<EmailSettings>;
readonly settingsForm: FormGroup;
readonly syncFailedWarningTip = SYNC_FAILED_WARNING_TIP;
constructor(
formBuilder: FormBuilder,
private changeDetector: ChangeDetectorRef,
private settingsSyncService: SettingsSyncService
) {
this.settingsForm = formBuilder.group({
srcAddr: ['', [Validators.required, Validators.email]],
dstAddr: ['', [Validators.required, Validators.email]],
authCode: ['', [Validators.required]],
smtpHost: ['', [Validators.required]],
smtpPort: ['', [Validators.required, Validators.pattern(/\d+/)]],
});
}
get srcAddrControl() {
return this.settingsForm.get('srcAddr') as FormControl;
}
get dstAddrControl() {
return this.settingsForm.get('dstAddr') as FormControl;
}
get authCodeControl() {
return this.settingsForm.get('authCode') as FormControl;
}
get smtpHostControl() {
return this.settingsForm.get('smtpHost') as FormControl;
}
get smtpPortControl() {
return this.settingsForm.get('smtpPort') as FormControl;
}
ngOnChanges(): void {
this.syncStatus = mapValues(this.settings, () => true);
this.settingsForm.setValue(this.settings);
}
ngOnInit(): void {
this.settingsSyncService
.syncSettings(
'emailNotification',
this.settings,
this.settingsForm.valueChanges.pipe(
filterValueChanges<EmailSettings>(this.settingsForm),
map((settings) =>
transform(
settings,
(result, value, prop) => {
value = prop === 'smtpPort' ? parseInt(value as string) : value;
Reflect.set(result, prop, value);
},
{} as EmailSettings
)
)
)
)
.subscribe((detail) => {
this.syncStatus = { ...this.syncStatus, ...calcSyncStatus(detail) };
this.changeDetector.markForCheck();
});
}
}

View File

@@ -0,0 +1,12 @@
<a class="setting-item" routerLink="email-notification"
><span class="setting-label">邮箱通知</span
><span class="setting-control"> <i nz-icon nzType="right"></i> </span
></a>
<a class="setting-item" routerLink="serverchan-notification"
><span class="setting-label">ServerChan 通知</span
><span class="setting-control"><i nz-icon nzType="right"></i></span
></a>
<a class="setting-item" routerLink="pushplus-notification"
><span class="setting-label">pushplus 通知</span
><span class="setting-control"><i nz-icon nzType="right"></i></span
></a>

View File

@@ -0,0 +1 @@
@use '../shared/styles/setting';

View File

@@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NotificationSettingsComponent } from './notification-settings.component';
describe('NotificationSettingsComponent', () => {
let component: NotificationSettingsComponent;
let fixture: ComponentFixture<NotificationSettingsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ NotificationSettingsComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(NotificationSettingsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,9 @@
import { Component, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-notification-settings',
templateUrl: './notification-settings.component.html',
styleUrls: ['./notification-settings.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NotificationSettingsComponent {}

View File

@@ -0,0 +1,23 @@
<app-sub-page pageTitle="pushplus 通知">
<ng-template appSubPageContent>
<app-page-section>
<app-notifier-settings
[settings]="notifierSettings"
keyOfSettings="pushplusNotification"
></app-notifier-settings>
</app-page-section>
<app-page-section name="pushplus">
<app-pushplus-settings
[settings]="pushplusSettings"
></app-pushplus-settings>
</app-page-section>
<app-page-section name="事件">
<app-event-settings
[settings]="notificationSettings"
keyOfSettings="pushplusNotification"
></app-event-settings>
</app-page-section>
</ng-template>
</app-sub-page>

View File

@@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PushplusNotificationSettingsComponent } from './pushplus-notification-settings.component';
describe('PushplusNotificationSettingsComponent', () => {
let component: PushplusNotificationSettingsComponent;
let fixture: ComponentFixture<PushplusNotificationSettingsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ PushplusNotificationSettingsComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(PushplusNotificationSettingsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,46 @@
import {
Component,
OnInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import pick from 'lodash-es/pick';
import {
NotificationSettings,
PushplusSettings,
NotifierSettings,
PushplusNotificationSettings,
KEYS_OF_PUSHPLUS_SETTINGS,
KEYS_OF_NOTIFIER_SETTINGS,
KEYS_OF_NOTIFICATION_SETTINGS,
} from '../../shared/setting.model';
@Component({
selector: 'app-pushplus-notification-settings',
templateUrl: './pushplus-notification-settings.component.html',
styleUrls: ['./pushplus-notification-settings.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PushplusNotificationSettingsComponent implements OnInit {
pushplusSettings!: PushplusSettings;
notifierSettings!: NotifierSettings;
notificationSettings!: NotificationSettings;
constructor(
private changeDetector: ChangeDetectorRef,
private route: ActivatedRoute
) {}
ngOnInit(): void {
this.route.data.subscribe((data) => {
const settings = data.settings as PushplusNotificationSettings;
this.changeDetector.markForCheck();
this.pushplusSettings = pick(settings, KEYS_OF_PUSHPLUS_SETTINGS);
this.notifierSettings = pick(settings, KEYS_OF_NOTIFIER_SETTINGS);
this.notificationSettings = pick(settings, KEYS_OF_NOTIFICATION_SETTINGS);
});
}
}

View File

@@ -0,0 +1,44 @@
<form nz-form [formGroup]="settingsForm">
<nz-form-item class="setting-item">
<nz-form-label
class="setting-label required"
nzFor="token"
nzNoColon
nzRequired
>token</nz-form-label
>
<nz-form-control
class="setting-control input"
nzHasFeedback
[nzErrorTip]="tokenErrorTip"
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="
tokenControl.valid && !syncStatus.token ? 'warning' : tokenControl
"
>
<input id="token" type="text" required nz-input formControlName="token" />
<ng-template #tokenErrorTip let-control>
<ng-container *ngIf="control.hasError('required')">
请输入 token
</ng-container>
<ng-container *ngIf="control.hasError('pattern')">
token 无效
</ng-container>
</ng-template>
</nz-form-control>
</nz-form-item>
<nz-form-item class="setting-item">
<nz-form-label class="setting-label align-required" nzFor="topic" nzNoColon
>topic</nz-form-label
>
<nz-form-control
class="setting-control input"
[nzWarningTip]="syncFailedWarningTip"
[nzValidateStatus]="
topicControl.valid && !syncStatus.topic ? 'warning' : topicControl
"
>
<input id="topic" type="text" nz-input formControlName="topic" />
</nz-form-control>
</nz-form-item>
</form>

View File

@@ -0,0 +1,6 @@
@use '../../../shared/styles/setting';
.setting-label {
max-width: 4em !important;
width: 4em !important;
}

View File

@@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PushplusSettingsComponent } from './pushplus-settings.component';
describe('PushplusSettingsComponent', () => {
let component: PushplusSettingsComponent;
let fixture: ComponentFixture<PushplusSettingsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ PushplusSettingsComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(PushplusSettingsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,78 @@
import {
Component,
OnInit,
ChangeDetectionStrategy,
Input,
OnChanges,
ChangeDetectorRef,
} from '@angular/core';
import {
FormBuilder,
FormControl,
FormGroup,
Validators,
} from '@angular/forms';
import mapValues from 'lodash-es/mapValues';
import { PushplusSettings } from '../../../shared/setting.model';
import { filterValueChanges } from '../../../shared/rx-operators';
import {
SettingsSyncService,
SyncStatus,
calcSyncStatus,
} from '../../../shared/services/settings-sync.service';
import { SYNC_FAILED_WARNING_TIP } from 'src/app/settings/shared/constants/form';
@Component({
selector: 'app-pushplus-settings',
templateUrl: './pushplus-settings.component.html',
styleUrls: ['./pushplus-settings.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PushplusSettingsComponent implements OnInit, OnChanges {
@Input() settings!: PushplusSettings;
syncStatus!: SyncStatus<PushplusSettings>;
readonly settingsForm: FormGroup;
readonly syncFailedWarningTip = SYNC_FAILED_WARNING_TIP;
constructor(
formBuilder: FormBuilder,
private changeDetector: ChangeDetectorRef,
private settingsSyncService: SettingsSyncService
) {
this.settingsForm = formBuilder.group({
token: ['', [Validators.required, Validators.pattern(/^[a-z\d]{32}$/)]],
topic: [''],
});
}
get tokenControl() {
return this.settingsForm.get('token') as FormControl;
}
get topicControl() {
return this.settingsForm.get('topic') as FormControl;
}
ngOnChanges(): void {
this.syncStatus = mapValues(this.settings, () => true);
this.settingsForm.setValue(this.settings);
}
ngOnInit(): void {
this.settingsSyncService
.syncSettings(
'pushplusNotification',
this.settings,
this.settingsForm.valueChanges.pipe(
filterValueChanges<PushplusSettings>(this.settingsForm)
)
)
.subscribe((detail) => {
this.syncStatus = { ...this.syncStatus, ...calcSyncStatus(detail) };
this.changeDetector.markForCheck();
});
}
}

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