release: v1.0.0
This commit is contained in:
17
webapp/.browserslistrc
Normal file
17
webapp/.browserslistrc
Normal 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
16
webapp/.editorconfig
Normal 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
51
webapp/.eslintrc.json
Normal 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
45
webapp/.gitignore
vendored
Normal 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
27
webapp/README.md
Normal 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
133
webapp/angular.json
Normal 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
44
webapp/karma.conf.js
Normal 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
30
webapp/ngsw-config.json
Normal 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
15156
webapp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
55
webapp/package.json
Normal file
55
webapp/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
20
webapp/src/app/about/about-routing.module.ts
Normal file
20
webapp/src/app/about/about-routing.module.ts
Normal 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 {}
|
||||
7
webapp/src/app/about/about.component.html
Normal file
7
webapp/src/app/about/about.component.html
Normal 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>
|
||||
10
webapp/src/app/about/about.component.scss
Normal file
10
webapp/src/app/about/about.component.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
@use '../shared/styles/layout';
|
||||
@use '../shared/styles/list';
|
||||
|
||||
.inner-content {
|
||||
@extend %inner-content;
|
||||
|
||||
.about-page {
|
||||
@extend %inner-page;
|
||||
}
|
||||
}
|
||||
25
webapp/src/app/about/about.component.spec.ts
Normal file
25
webapp/src/app/about/about.component.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
24
webapp/src/app/about/about.component.ts
Normal file
24
webapp/src/app/about/about.component.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
25
webapp/src/app/about/about.module.ts
Normal file
25
webapp/src/app/about/about.module.ts
Normal 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 { }
|
||||
50
webapp/src/app/about/info-list/info-list.component.html
Normal file
50
webapp/src/app/about/info-list/info-list.component.html
Normal 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>
|
||||
13
webapp/src/app/about/info-list/info-list.component.scss
Normal file
13
webapp/src/app/about/info-list/info-list.component.scss
Normal file
@@ -0,0 +1,13 @@
|
||||
@use '../../shared/styles/list';
|
||||
|
||||
.info-list {
|
||||
@include list.reset-list;
|
||||
|
||||
.info-item {
|
||||
@extend %list-item;
|
||||
|
||||
.label {
|
||||
@extend %item-label;
|
||||
}
|
||||
}
|
||||
}
|
||||
25
webapp/src/app/about/info-list/info-list.component.spec.ts
Normal file
25
webapp/src/app/about/info-list/info-list.component.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
22
webapp/src/app/about/info-list/info-list.component.ts
Normal file
22
webapp/src/app/about/info-list/info-list.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
40
webapp/src/app/about/shared/services/app-info.resolver.ts
Normal file
40
webapp/src/app/about/shared/services/app-info.resolver.ts
Normal 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;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
38
webapp/src/app/app-routing.module.ts
Normal file
38
webapp/src/app/app-routing.module.ts
Normal 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 {}
|
||||
108
webapp/src/app/app.component.html
Normal file
108
webapp/src/app/app.component.html
Normal 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>
|
||||
136
webapp/src/app/app.component.scss
Normal file
136
webapp/src/app/app.component.scss
Normal 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;
|
||||
}
|
||||
35
webapp/src/app/app.component.spec.ts
Normal file
35
webapp/src/app/app.component.spec.ts
Normal 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 站直播录制');
|
||||
});
|
||||
});
|
||||
73
webapp/src/app/app.component.ts
Normal file
73
webapp/src/app/app.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
69
webapp/src/app/app.module.ts
Normal file
69
webapp/src/app/app.module.ts
Normal 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 {}
|
||||
14
webapp/src/app/core/core.module.ts
Normal file
14
webapp/src/app/core/core.module.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
36
webapp/src/app/core/http-interceptors/auth.interceptor.ts
Normal file
36
webapp/src/app/core/http-interceptors/auth.interceptor.ts
Normal 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;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
9
webapp/src/app/core/http-interceptors/index.ts
Normal file
9
webapp/src/app/core/http-interceptors/index.ts
Normal 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 },
|
||||
];
|
||||
16
webapp/src/app/core/models/app.models.ts
Normal file
16
webapp/src/app/core/models/app.models.ts
Normal 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;
|
||||
}
|
||||
53
webapp/src/app/core/models/event.model.ts
Normal file
53
webapp/src/app/core/models/event.model.ts
Normal 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;
|
||||
}
|
||||
4
webapp/src/app/core/models/validation.model.ts
Normal file
4
webapp/src/app/core/models/validation.model.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export enum ERRNO {
|
||||
EACCES = 13,
|
||||
ENOTDIR = 20,
|
||||
}
|
||||
16
webapp/src/app/core/services/app.service.spec.ts
Normal file
16
webapp/src/app/core/services/app.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
36
webapp/src/app/core/services/app.service.ts
Normal file
36
webapp/src/app/core/services/app.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
16
webapp/src/app/core/services/auth.service.spec.ts
Normal file
16
webapp/src/app/core/services/auth.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
19
webapp/src/app/core/services/auth.service.ts
Normal file
19
webapp/src/app/core/services/auth.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
16
webapp/src/app/core/services/event.service.spec.ts
Normal file
16
webapp/src/app/core/services/event.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
24
webapp/src/app/core/services/event.service.ts
Normal file
24
webapp/src/app/core/services/event.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
16
webapp/src/app/core/services/exception.service.spec.ts
Normal file
16
webapp/src/app/core/services/exception.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
24
webapp/src/app/core/services/exception.service.ts
Normal file
24
webapp/src/app/core/services/exception.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
122
webapp/src/app/core/services/router-scroll.service.intf.ts
Normal file
122
webapp/src/app/core/services/router-scroll.service.intf.ts
Normal 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[];
|
||||
281
webapp/src/app/core/services/router-scroll.service.ts
Normal file
281
webapp/src/app/core/services/router-scroll.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
16
webapp/src/app/core/services/storage.service.spec.ts
Normal file
16
webapp/src/app/core/services/storage.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
30
webapp/src/app/core/services/storage.service.ts
Normal file
30
webapp/src/app/core/services/storage.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
16
webapp/src/app/core/services/update.service.spec.ts
Normal file
16
webapp/src/app/core/services/update.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
20
webapp/src/app/core/services/update.service.ts
Normal file
20
webapp/src/app/core/services/update.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
16
webapp/src/app/core/services/validation.service.spec.ts
Normal file
16
webapp/src/app/core/services/validation.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
21
webapp/src/app/core/services/validation.service.ts
Normal file
21
webapp/src/app/core/services/validation.service.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
21
webapp/src/app/icons-provider.module.ts
Normal file
21
webapp/src/app/icons-provider.module.ts
Normal 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 {
|
||||
}
|
||||
@@ -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>
|
||||
34
webapp/src/app/page-not-found/page-not-found.component.scss
Normal file
34
webapp/src/app/page-not-found/page-not-found.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
16
webapp/src/app/page-not-found/page-not-found.component.ts
Normal file
16
webapp/src/app/page-not-found/page-not-found.component.ts
Normal 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 {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
@use '../shared/styles/setting';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
@use '../shared/styles/setting';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
@use '../../shared/styles/setting';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
@use '../shared/styles/setting';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
@use '../../shared/styles/setting';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
@use '../shared/styles/setting';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,2 @@
|
||||
|
||||
@use '../../shared/styles/setting';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
@use '../../../shared/styles/setting';
|
||||
|
||||
.setting-label {
|
||||
max-width: 6em !important;
|
||||
width: 6em !important;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
@use '../shared/styles/setting';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 {}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,2 @@
|
||||
|
||||
@use '../../shared/styles/setting';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
@use '../../../shared/styles/setting';
|
||||
|
||||
.setting-label {
|
||||
max-width: 4em !important;
|
||||
width: 4em !important;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user