├── src ├── assets │ └── .gitkeep ├── app │ ├── single │ │ ├── construction │ │ │ ├── construction.component.scss │ │ │ ├── construction.component.html │ │ │ └── construction.component.ts │ │ └── not-found │ │ │ ├── not-found.component.html │ │ │ ├── not-found.component.scss │ │ │ └── not-found.component.ts │ ├── test │ │ ├── test.component.html │ │ ├── test.component.scss │ │ └── test.component.ts │ ├── todo │ │ ├── project-editing-dialog │ │ │ ├── project-editing-dialog.component.scss │ │ │ ├── project-editing-dialog.model.ts │ │ │ ├── project-editing-dialog.component.html │ │ │ └── project-editing-dialog.component.ts │ │ └── todo-home │ │ │ ├── todo.model.ts │ │ │ ├── todo-home.component.scss │ │ │ ├── todo.service.ts │ │ │ ├── todo-home.component.ts │ │ │ └── todo-home.component.html │ ├── common │ │ ├── components │ │ │ ├── footer │ │ │ │ ├── footer.component.html │ │ │ │ ├── footer.component.ts │ │ │ │ └── footer.component.scss │ │ │ └── header │ │ │ │ ├── header.component.html │ │ │ │ ├── header.component.scss │ │ │ │ └── header.component.ts │ │ ├── core │ │ │ ├── logger.ts │ │ │ └── storage.ts │ │ ├── interceptors │ │ │ ├── auth.interceptor.ts │ │ │ ├── url.interceptor.ts │ │ │ ├── error-code.interceptor.ts │ │ │ └── signature.interceptor.ts │ │ └── services │ │ │ └── auth.service.ts │ ├── app.component.ts │ ├── app.routes.ts │ ├── app.config.ts │ ├── home │ │ ├── home.component.ts │ │ ├── home.component.html │ │ ├── home-data.service.ts │ │ └── home.component.scss │ └── login │ │ ├── login.component.html │ │ ├── login.service.ts │ │ ├── login.component.ts │ │ └── login.component.scss ├── favicon.ico ├── environments │ └── environment.ts ├── main.ts ├── index.html └── styles.scss ├── .stylelintrc.json ├── .vscode ├── extensions.json ├── launch.json └── tasks.json ├── .eslintrc.json ├── tsconfig.app.json ├── tsconfig.spec.json ├── .editorconfig ├── .prettierrc ├── .gitignore ├── tsconfig.json ├── LICENSE ├── package.json ├── angular.json └── README.md /src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/single/construction/construction.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/test/test.component.html: -------------------------------------------------------------------------------- 1 |
2 | -------------------------------------------------------------------------------- /src/app/todo/project-editing-dialog/project-editing-dialog.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/single/construction/construction.component.html: -------------------------------------------------------------------------------- 1 |

construction works!

2 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inlym/life-helper-web/HEAD/src/favicon.ico -------------------------------------------------------------------------------- /src/app/single/not-found/not-found.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["stylelint-config-standard-scss", "stylelint-config-recess-order"] 3 | } 4 | -------------------------------------------------------------------------------- /src/app/test/test.component.scss: -------------------------------------------------------------------------------- 1 | .main { 2 | width: 100%; 3 | height: 1000px; 4 | border: 1px solid red; 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 3 | "recommendations": ["angular.ng-template"] 4 | } 5 | -------------------------------------------------------------------------------- /src/app/single/not-found/not-found.component.scss: -------------------------------------------------------------------------------- 1 | .main { 2 | height: 100px; 3 | margin-top: 10px; 4 | font-size: 100px; 5 | background-color: aquamarine; 6 | } 7 | -------------------------------------------------------------------------------- /src/app/todo/project-editing-dialog/project-editing-dialog.model.ts: -------------------------------------------------------------------------------- 1 | /** 打开弹窗时,传递的数据 */ 2 | export interface ProjectEditingDialogInputData { 3 | /** 项目 ID */ 4 | id: number 5 | /** 项目名称 */ 6 | name: string 7 | } 8 | -------------------------------------------------------------------------------- /src/app/common/components/footer/footer.component.html: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": [ 4 | "eslint:recommended", 5 | "plugin:@typescript-eslint/eslint-recommended", 6 | "plugin:@typescript-eslint/recommended", 7 | "prettier" 8 | ], 9 | "plugins": ["@typescript-eslint", "prettier"], 10 | "rules": {} 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": [ 9 | "src/main.ts" 10 | ], 11 | "include": [ 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": [ 7 | "jasmine" 8 | ] 9 | }, 10 | "include": [ 11 | "src/**/*.spec.ts", 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | /** 环境名称 */ 3 | name: 'development', 4 | 5 | /** 是否为生产环境 */ 6 | production: false, 7 | 8 | /** 服务端请求地址 */ 9 | baseURL: 'https://api-local.lifehelper.com.cn', 10 | 11 | /** 阿里云 API 网关签名认证密钥 */ 12 | apigateway: { 13 | key: 'xxxxxxxxxx', 14 | secret: 'xxxxxxxxxx', 15 | }, 16 | } 17 | -------------------------------------------------------------------------------- /src/app/common/components/footer/footer.component.ts: -------------------------------------------------------------------------------- 1 | import {Component} from '@angular/core' 2 | import {CommonModule} from '@angular/common' 3 | 4 | @Component({ 5 | selector: 'app-footer', 6 | standalone: true, 7 | imports: [CommonModule], 8 | templateUrl: './footer.component.html', 9 | styleUrl: './footer.component.scss', 10 | }) 11 | export class FooterComponent {} 12 | -------------------------------------------------------------------------------- /src/app/common/core/logger.ts: -------------------------------------------------------------------------------- 1 | import * as log from 'loglevel' 2 | import { environment } from '../../../environments/environment' 3 | 4 | // ========== 备注(2023.11.18) ========== 5 | // 临时先用这个库来打日志,后续自己写一个专用的 6 | // ====================================== 7 | 8 | if (environment.production) { 9 | log.setLevel('ERROR') 10 | } else { 11 | log.setLevel('TRACE') 12 | } 13 | 14 | export const logger = log 15 | -------------------------------------------------------------------------------- /src/app/common/components/footer/footer.component.scss: -------------------------------------------------------------------------------- 1 | .footer { 2 | height: 30px; 3 | font-size: 12px; 4 | line-height: 30px; 5 | color: var(--ming-text-desc); 6 | text-align: center; 7 | background-color: var(--ming-bg-1); 8 | 9 | span { 10 | margin: 0 10px; 11 | } 12 | 13 | a { 14 | color: var(--ming-text-desc); 15 | } 16 | 17 | a:hover { 18 | color: var(--ming-text-link); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | 18 | [*.html] 19 | indent_size = 4 20 | 21 | [*.json] 22 | indent_size = 2 23 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { bootstrapApplication } from '@angular/platform-browser' 2 | import { appConfig } from './app/app.config' 3 | import { AppComponent } from './app/app.component' 4 | import { environment } from './environments/environment' 5 | import { enableProdMode } from '@angular/core' 6 | 7 | if (environment.production) { 8 | enableProdMode() 9 | } 10 | 11 | bootstrapApplication(AppComponent, appConfig).catch((err) => console.error(err)) 12 | -------------------------------------------------------------------------------- /src/app/single/construction/construction.component.ts: -------------------------------------------------------------------------------- 1 | import {Component} from '@angular/core' 2 | import {CommonModule} from '@angular/common' 3 | 4 | // 说明 5 | // 部分模块仍在开发中,就直接跳转到这里作为过渡 6 | 7 | @Component({ 8 | selector: 'app-construction', 9 | standalone: true, 10 | imports: [CommonModule], 11 | templateUrl: './construction.component.html', 12 | styleUrl: './construction.component.scss', 13 | }) 14 | export class ConstructionComponent {} 15 | -------------------------------------------------------------------------------- /src/app/single/not-found/not-found.component.ts: -------------------------------------------------------------------------------- 1 | import {CommonModule} from '@angular/common' 2 | import {Component} from '@angular/core' 3 | import {HeaderComponent} from '../../common/components/header/header.component' 4 | 5 | @Component({ 6 | selector: 'app-not-found', 7 | standalone: true, 8 | imports: [CommonModule, HeaderComponent], 9 | templateUrl: './not-found.component.html', 10 | styleUrl: './not-found.component.scss', 11 | }) 12 | export class NotFoundComponent {} 13 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": false, 6 | "singleQuote": true, 7 | "quoteProps": "as-needed", 8 | "trailingComma": "es5", 9 | "bracketSpacing": false, 10 | "bracketSameLine": false, 11 | "arrowParens": "always", 12 | "endOfLine": "lf", 13 | "embeddedLanguageFormatting": "auto", 14 | "overrides": [ 15 | { 16 | "files": "*.html", 17 | "options": { 18 | "tabWidth": 4 19 | } 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /src/app/test/test.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common' 2 | import { Component, OnInit } from '@angular/core' 3 | import { logger } from '../common/core/logger' 4 | 5 | /** 临时调试或者测试用途的组件 */ 6 | @Component({ 7 | selector: 'app-test', 8 | standalone: true, 9 | imports: [CommonModule], 10 | templateUrl: './test.component.html', 11 | styleUrl: './test.component.scss', 12 | }) 13 | export class TestComponent implements OnInit { 14 | ngOnInit(): void { 15 | logger.debug('打开调试页') 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common' 2 | import { Component, OnInit } from '@angular/core' 3 | import { RouterOutlet } from '@angular/router' 4 | 5 | @Component({ 6 | selector: 'app-root', 7 | standalone: true, 8 | imports: [CommonModule, RouterOutlet], 9 | template: '', 10 | }) 11 | export class AppComponent implements OnInit { 12 | constructor() {} 13 | 14 | ngOnInit(): void { 15 | // 主组件,仅提供路由,无模板 16 | } 17 | 18 | title = 'life-helper-web' 19 | } 20 | -------------------------------------------------------------------------------- /src/app/common/interceptors/auth.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { HttpInterceptorFn } from '@angular/common/http' 2 | import { inject } from '@angular/core' 3 | import { AuthService } from '../services/auth.service' 4 | 5 | /** 权限拦截器 */ 6 | export const authInterceptor: HttpInterceptorFn = (req, next) => { 7 | const certificate = inject(AuthService).getCertificate() 8 | if (certificate) { 9 | const { token, headerName } = certificate 10 | req = req.clone({ setHeaders: { [headerName]: token } }) 11 | } 12 | 13 | return next(req) 14 | } 15 | -------------------------------------------------------------------------------- /src/app/todo/todo-home/todo.model.ts: -------------------------------------------------------------------------------- 1 | /** 待办项目 */ 2 | export interface Project { 3 | /** 项目 ID */ 4 | id: number 5 | /** 项目名称 */ 6 | name: string 7 | } 8 | 9 | /** 专用于编辑框使用的数据格式 */ 10 | export type EditedProject = Pick 11 | 12 | /** 待办任务 */ 13 | export interface Task { 14 | /** 任务 ID */ 15 | id: number 16 | /** 任务标题 */ 17 | title: string 18 | /** 任务描述 */ 19 | description: string 20 | } 21 | 22 | /** 待办任务标签 */ 23 | export interface Tag { 24 | /** 标签 ID */ 25 | id: number 26 | /** 标签名称 */ 27 | name: string 28 | } 29 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "name": "ng serve", 7 | "type": "chrome", 8 | "request": "launch", 9 | "preLaunchTask": "npm: start", 10 | "url": "http://localhost:4200/" 11 | }, 12 | { 13 | "name": "ng test", 14 | "type": "chrome", 15 | "request": "launch", 16 | "preLaunchTask": "npm: test", 17 | "url": "http://localhost:9876/debug.html" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /src/app/common/interceptors/url.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { HttpInterceptorFn } from '@angular/common/http' 2 | import { environment } from '../../../environments/environment' 3 | 4 | /** 请求地址前缀 */ 5 | const { baseURL } = environment 6 | 7 | /** 8 | * 请求地址拦截器 9 | * 10 | * ### 主要用途 11 | * 根据当前环境在请求路径加上请求地址前缀。 12 | */ 13 | export const urlInterceptor: HttpInterceptorFn = (req, next) => { 14 | if (req.url.startsWith('http://') || req.url.startsWith('https://')) { 15 | return next(req) 16 | } else { 17 | return next(req.clone({ url: baseURL + req.url })) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 小鸣助手 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/app/app.routes.ts: -------------------------------------------------------------------------------- 1 | import {Routes} from '@angular/router' 2 | import {HomeComponent} from './home/home.component' 3 | import {LoginComponent} from './login/login.component' 4 | import {NotFoundComponent} from './single/not-found/not-found.component' 5 | import {TestComponent} from './test/test.component' 6 | import {TodoHomeComponent} from './todo/todo-home/todo-home.component' 7 | 8 | export const routes: Routes = [ 9 | {path: '', component: HomeComponent}, 10 | {path: 'login', component: LoginComponent}, 11 | {path: 'test', component: TestComponent}, 12 | {path: 'todo', component: TodoHomeComponent}, 13 | {path: '**', component: NotFoundComponent}, 14 | ] 15 | -------------------------------------------------------------------------------- /src/app/common/interceptors/error-code.interceptor.ts: -------------------------------------------------------------------------------- 1 | import {HttpInterceptorFn} from '@angular/common/http' 2 | import {inject} from '@angular/core' 3 | import {Router} from '@angular/router' 4 | import {catchError, throwError} from 'rxjs' 5 | 6 | /** 错误码处理拦截器 */ 7 | export const errorCodeInterceptor: HttpInterceptorFn = (req, next) => { 8 | const router = inject(Router) 9 | 10 | return next(req).pipe( 11 | catchError((error) => { 12 | // 自定义错误码(非HTTP状态码) 13 | const errorCode = error.error.errorCode 14 | 15 | if (errorCode === 2) { 16 | router.navigateByUrl('/login') 17 | } 18 | 19 | return throwError(() => error) 20 | }) 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/app/todo/todo-home/todo-home.component.scss: -------------------------------------------------------------------------------- 1 | // 最外层的容器 2 | .container { 3 | height: 100%; 4 | width: 100%; 5 | display: flex; 6 | 7 | // 左侧菜单栏 8 | .menu { 9 | padding: 10px; 10 | width: 260px; 11 | background-color: #e5e5e5; 12 | 13 | // 分割线 14 | .divider { 15 | height: 1px; 16 | background-color: #d1d1d1; 17 | margin: 10px 0; 18 | } 19 | 20 | // 快捷操作区 21 | .shortcut { 22 | .item { 23 | height: 60px; 24 | line-height: 60px; 25 | } 26 | } 27 | 28 | .projects-area, 29 | .tags-area { 30 | .header { 31 | padding-left: 10px; 32 | display: flex; 33 | justify-content: space-between; 34 | align-items: center; 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/app/common/components/header/header.component.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |
5 | 6 |
小鸣助手
7 |
8 |
9 | 14 |
15 |
16 | 17 |
18 |
19 |
20 | -------------------------------------------------------------------------------- /src/app/todo/project-editing-dialog/project-editing-dialog.component.html: -------------------------------------------------------------------------------- 1 | 2 | @if (isNew) { 3 |

添加清单

4 | } @else { 5 |

编辑清单

6 | } 7 | 8 | 9 | 10 | 名称 11 | 12 | 请输入清单名称,最多10字 13 | 14 | 15 | 16 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # Compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /bazel-out 8 | 9 | # Node 10 | /node_modules 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # IDEs and editors 15 | .idea/ 16 | .project 17 | .classpath 18 | .c9/ 19 | *.launch 20 | .settings/ 21 | *.sublime-workspace 22 | 23 | # Visual Studio Code 24 | .vscode/* 25 | !.vscode/settings.json 26 | !.vscode/tasks.json 27 | !.vscode/launch.json 28 | !.vscode/extensions.json 29 | .history/* 30 | 31 | # Miscellaneous 32 | /.angular/cache 33 | .sass-cache/ 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | testem.log 38 | /typings 39 | 40 | # System files 41 | .DS_Store 42 | Thumbs.db 43 | 44 | # Config files 45 | src/environments/environment.prod.ts 46 | 47 | # Others 48 | pnpm-lock.yaml 49 | -------------------------------------------------------------------------------- /src/app/common/core/storage.ts: -------------------------------------------------------------------------------- 1 | /** 缓存使用的键名枚举 */ 2 | export enum StorageItem { 3 | /** 用户登录鉴权凭证 */ 4 | TOKEN = 'token', 5 | /** 用户信息 */ 6 | USER_INFO = 'user_info', 7 | } 8 | 9 | /** 10 | * 获取缓存值 11 | * @param key 键名 12 | * 13 | * @since 2.0.0 14 | * @date 2023/11/19 15 | */ 16 | function getItem(key: StorageItem): T | null { 17 | const str = localStorage.getItem(key) 18 | if (str !== null) { 19 | return JSON.parse(str) 20 | } 21 | return null 22 | } 23 | 24 | /** 25 | * 设置缓存值 26 | * @param key 键名 27 | * @param value 键值,可以为任意值 28 | * 29 | * @since 2.0.0 30 | * @date 2023/11/19 31 | */ 32 | function setItem(key: StorageItem, value: any): void { 33 | if (value !== undefined && value !== null) { 34 | localStorage.setItem(key, JSON.stringify(value)) 35 | } 36 | } 37 | 38 | export const storage = { getItem, setItem } 39 | -------------------------------------------------------------------------------- /src/app/app.config.ts: -------------------------------------------------------------------------------- 1 | import {provideHttpClient, withInterceptors} from '@angular/common/http' 2 | import {ApplicationConfig} from '@angular/core' 3 | import {provideAnimations} from '@angular/platform-browser/animations' 4 | import {provideRouter} from '@angular/router' 5 | import {routes} from './app.routes' 6 | import {authInterceptor} from './common/interceptors/auth.interceptor' 7 | import {signatureInterceptor} from './common/interceptors/signature.interceptor' 8 | import {urlInterceptor} from './common/interceptors/url.interceptor' 9 | import {errorCodeInterceptor} from './common/interceptors/error-code.interceptor' 10 | 11 | const httpInterceptors = [urlInterceptor, authInterceptor, signatureInterceptor, errorCodeInterceptor] 12 | 13 | export const appConfig: ApplicationConfig = { 14 | providers: [provideRouter(routes), provideHttpClient(withInterceptors(httpInterceptors)), provideAnimations()], 15 | } 16 | -------------------------------------------------------------------------------- /src/app/todo/todo-home/todo.service.ts: -------------------------------------------------------------------------------- 1 | import {HttpClient} from '@angular/common/http' 2 | import {Injectable} from '@angular/core' 3 | import {of} from 'rxjs' 4 | import {Project, Tag} from './todo.model' 5 | 6 | @Injectable({providedIn: 'root'}) 7 | export class TodoService { 8 | constructor(private http: HttpClient) {} 9 | 10 | /** 11 | * 添加待办清单 12 | * @param name 清单名称 13 | */ 14 | addProject(name: string) { 15 | return this.http.post('/todo/project', {name}) 16 | } 17 | 18 | getProjectList() { 19 | const list: Project[] = [ 20 | {id: 1, name: '2dfdsf'}, 21 | {id: 2, name: '23432e'}, 22 | {id: 3, name: '32424'}, 23 | ] 24 | return of(list) 25 | } 26 | 27 | getTagList() { 28 | const list: Tag[] = [ 29 | {id: 666, name: '标签3号'}, 30 | {id: 333, name: '标签2号'}, 31 | {id: 232, name: '标签1号'}, 32 | ] 33 | return of(list) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "outDir": "./dist/out-tsc", 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "noImplicitOverride": true, 9 | "noPropertyAccessFromIndexSignature": true, 10 | "noImplicitReturns": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "esModuleInterop": true, 13 | "sourceMap": true, 14 | "declaration": false, 15 | "experimentalDecorators": true, 16 | "moduleResolution": "node", 17 | "importHelpers": true, 18 | "target": "ES2022", 19 | "module": "ES2022", 20 | "useDefineForClassFields": false, 21 | "lib": [ 22 | "ES2022", 23 | "dom" 24 | ] 25 | }, 26 | "angularCompilerOptions": { 27 | "enableI18nLegacyMessageIdFormat": false, 28 | "strictInjectionParameters": true, 29 | "strictInputAccessModifiers": true, 30 | "strictTemplates": true 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/app/home/home.component.ts: -------------------------------------------------------------------------------- 1 | import {Component} from '@angular/core' 2 | import {CommonModule} from '@angular/common' 3 | import {Customer, DeviceInfo, HomeDataService} from './home-data.service' 4 | import {HeaderComponent} from '../common/components/header/header.component' 5 | import {FooterComponent} from '../common/components/footer/footer.component' 6 | 7 | @Component({ 8 | selector: 'app-home', 9 | standalone: true, 10 | imports: [CommonModule, HeaderComponent, FooterComponent], 11 | templateUrl: './home.component.html', 12 | styleUrl: './home.component.scss', 13 | }) 14 | export class HomeComponent { 15 | constructor(private homeDataService: HomeDataService) {} 16 | 17 | public img1Url = 'https://static.lifehelper.com.cn/web/001.png' 18 | 19 | /** 获取用户评价列表 */ 20 | get customers(): Customer[] { 21 | return this.homeDataService.customers 22 | } 23 | 24 | /** 获取客户端设备信息列表 */ 25 | get devices(): DeviceInfo[] { 26 | return this.homeDataService.devices 27 | } 28 | 29 | /** 处理客户端设备卡片点击事件 */ 30 | onDeviceItemClick(type: string) { 31 | console.log(type) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 3 | "version": "2.0.0", 4 | "tasks": [ 5 | { 6 | "type": "npm", 7 | "script": "start", 8 | "isBackground": true, 9 | "problemMatcher": { 10 | "owner": "typescript", 11 | "pattern": "$tsc", 12 | "background": { 13 | "activeOnStart": true, 14 | "beginsPattern": { 15 | "regexp": "(.*?)" 16 | }, 17 | "endsPattern": { 18 | "regexp": "bundle generation complete" 19 | } 20 | } 21 | } 22 | }, 23 | { 24 | "type": "npm", 25 | "script": "test", 26 | "isBackground": true, 27 | "problemMatcher": { 28 | "owner": "typescript", 29 | "pattern": "$tsc", 30 | "background": { 31 | "activeOnStart": true, 32 | "beginsPattern": { 33 | "regexp": "(.*?)" 34 | }, 35 | "endsPattern": { 36 | "regexp": "bundle generation complete" 37 | } 38 | } 39 | } 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 inlym 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/app/login/login.component.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 |
5 |

小鸣助手

6 |
超好用的生活辅助应用
7 |
使用微信扫一扫登录
8 |
9 | 10 | 11 | @if (scanned) { 12 |
13 |
check_circle
14 |
已扫码,请在手机上确认
15 |
} 16 | 17 | 18 | @if (invalid) { 19 |
20 |
error
21 |
已失效,请刷新重试
22 |
} 23 | 24 |
25 | 26 | 27 |
28 |
刷新
29 |
refresh
30 |
31 |
32 |
33 | -------------------------------------------------------------------------------- /src/app/common/components/header/header.component.scss: -------------------------------------------------------------------------------- 1 | .header { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | z-index: 99; 6 | width: 100%; 7 | height: var(--ming-header-height); 8 | background-color: transparent; 9 | transition: all 0.5s linear; 10 | 11 | .header-content { 12 | display: flex; 13 | align-items: center; 14 | justify-content: space-between; 15 | 16 | .space { 17 | flex: 1; 18 | } 19 | 20 | .left { 21 | display: flex; 22 | 23 | .logo { 24 | width: 26px; 25 | margin-right: 16px; 26 | } 27 | 28 | .title { 29 | font-size: 20px; 30 | font-weight: bold; 31 | } 32 | } 33 | 34 | .menus { 35 | display: flex; 36 | font-size: 18px; 37 | 38 | .menu-item { 39 | height: var(--ming-header-height); 40 | padding: 0 10px; 41 | margin: 0 50px; 42 | line-height: var(--ming-header-height); 43 | } 44 | 45 | .active { 46 | font-weight: bold; 47 | } 48 | } 49 | } 50 | } 51 | 52 | // 滚动条距离顶部 >0px 情况,做了样式强化 53 | .scrolled { 54 | background-color: var(--ming-bg-2); 55 | box-shadow: 0 10px 40px -15px rgb(64 90 163 / 20%); 56 | } 57 | -------------------------------------------------------------------------------- /src/app/common/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core' 2 | import { storage, StorageItem } from '../core/storage' 3 | 4 | /** 登录凭证 */ 5 | export interface IdentityCertificate { 6 | /** 鉴权令牌 */ 7 | token: string 8 | /** 安全令牌类型 */ 9 | type: 'JSON_WEB_TOKEN' | 'SIMPLE_TOKEN' 10 | /** 发起请求时,携带令牌的请求头名称 */ 11 | headerName: string 12 | /** 创建时间 */ 13 | createTime: string 14 | /** 过期时间 */ 15 | expireTime: string 16 | } 17 | 18 | /** 19 | * 登录凭证管理服务 20 | * 21 | * @since 2.0.0 22 | * @date 2023/11/19 23 | */ 24 | @Injectable({ providedIn: 'root' }) 25 | export class AuthService { 26 | /** 27 | * 存储登录凭证 28 | * @param token 由服务器返回的整个登录凭证 29 | * 30 | * @since 2.0.0 31 | * @date 2023/11/19 32 | */ 33 | saveCertificate(token: IdentityCertificate): void { 34 | storage.setItem(StorageItem.TOKEN, token) 35 | } 36 | 37 | /** 38 | * 获取登录凭证 39 | * 40 | * @since 2.0.0 41 | * @date 2023/11/19 42 | */ 43 | getCertificate(): IdentityCertificate | null { 44 | const token = storage.getItem(StorageItem.TOKEN) 45 | if (token && new Date(token.expireTime).getTime() > Date.now()) { 46 | return token 47 | } 48 | 49 | return null 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/app/todo/project-editing-dialog/project-editing-dialog.component.ts: -------------------------------------------------------------------------------- 1 | import {CommonModule} from '@angular/common' 2 | import {Component, Inject} from '@angular/core' 3 | import {FormsModule} from '@angular/forms' 4 | import {MatButtonModule} from '@angular/material/button' 5 | import {MAT_DIALOG_DATA, MatDialogModule, MatDialogRef} from '@angular/material/dialog' 6 | import {MatFormFieldModule} from '@angular/material/form-field' 7 | import {MatInputModule} from '@angular/material/input' 8 | import {ProjectEditingDialogInputData} from './project-editing-dialog.model' 9 | import {TodoService} from '../todo-home/todo.service' 10 | 11 | @Component({ 12 | selector: 'app-project-editing-dialog', 13 | standalone: true, 14 | imports: [CommonModule, FormsModule, MatDialogModule, MatButtonModule, MatInputModule, MatFormFieldModule], 15 | templateUrl: './project-editing-dialog.component.html', 16 | styleUrl: './project-editing-dialog.component.scss', 17 | }) 18 | export class ProjectEditingDialogComponent { 19 | constructor( 20 | public dialogRef: MatDialogRef, 21 | @Inject(MAT_DIALOG_DATA) public data: ProjectEditingDialogInputData, 22 | private todoService: TodoService 23 | ) { 24 | this.isNew = !data.id 25 | this.name = data.name 26 | } 27 | 28 | /** 是否是“新建” */ 29 | isNew: boolean = true 30 | 31 | /** 清单名称 */ 32 | name: string = '' 33 | 34 | confirm() { 35 | this.todoService.addProject(this.name).subscribe((data) => console.log(data)) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/app/todo/todo-home/todo-home.component.ts: -------------------------------------------------------------------------------- 1 | import {CommonModule} from '@angular/common' 2 | import {Component, OnInit} from '@angular/core' 3 | import {MatButtonModule} from '@angular/material/button' 4 | import {MatDialog, MatDialogModule} from '@angular/material/dialog' 5 | import {MatIconModule} from '@angular/material/icon' 6 | import {ProjectEditingDialogComponent} from '../project-editing-dialog/project-editing-dialog.component' 7 | import {Project, Tag} from './todo.model' 8 | import {TodoService} from './todo.service' 9 | 10 | @Component({ 11 | selector: 'app-todo-home', 12 | standalone: true, 13 | imports: [CommonModule, MatIconModule, MatButtonModule, MatDialogModule], 14 | templateUrl: './todo-home.component.html', 15 | styleUrl: './todo-home.component.scss', 16 | }) 17 | export class TodoHomeComponent implements OnInit { 18 | projectList: Project[] = [] 19 | tagList: Tag[] = [] 20 | 21 | constructor( 22 | private todoService: TodoService, 23 | public dialog: MatDialog 24 | ) {} 25 | 26 | ngOnInit(): void { 27 | this.todoService.getProjectList().subscribe((data) => { 28 | this.projectList = data 29 | }) 30 | 31 | this.todoService.getTagList().subscribe((data) => { 32 | this.tagList = data 33 | }) 34 | } 35 | 36 | /** 打开项目编辑弹窗 */ 37 | openProjectEditingDialog() { 38 | const dialogRef = this.dialog.open(ProjectEditingDialogComponent, { 39 | data: {}, 40 | }) 41 | 42 | dialogRef.afterClosed().subscribe((data) => console.log(data)) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/app/common/components/header/header.component.ts: -------------------------------------------------------------------------------- 1 | import {CommonModule} from '@angular/common' 2 | import {Component, OnInit} from '@angular/core' 3 | import {Router} from '@angular/router' 4 | import {fromEvent} from 'rxjs' 5 | 6 | export interface HeaderClasses { 7 | header: boolean 8 | 'touch-top': boolean 9 | 'fixed-top': boolean 10 | } 11 | 12 | @Component({ 13 | selector: 'app-header', 14 | standalone: true, 15 | imports: [CommonModule], 16 | templateUrl: './header.component.html', 17 | styleUrl: './header.component.scss', 18 | }) 19 | export class HeaderComponent implements OnInit { 20 | /** 21 | * 滚动条是否发生滚动 22 | * 23 | * ### 说明 24 | * 1. 根据滚动条发生位移的距离定义该值。 25 | * 2. `>0px` => true, `=0px` => false 26 | */ 27 | public scrolled = false 28 | 29 | /** logo 图片地址 */ 30 | public logoUrl = 'https://static.lifehelper.com.cn/static/project/logo.svg' 31 | 32 | constructor(private router: Router) {} 33 | 34 | ngOnInit(): void { 35 | // 监听滚动条滚动事件 36 | fromEvent(window, 'scroll').subscribe(() => { 37 | this.handleScrolling() 38 | }) 39 | } 40 | 41 | /** 处理顶部导航栏的样式类 */ 42 | handleScrolling() { 43 | const distance = document.documentElement.scrollTop 44 | 45 | // [备注]:加上后半句判断是为了避免重复赋值 46 | if (distance > 0 && !this.scrolled) { 47 | this.scrolled = true 48 | } else if (distance === 0 && this.scrolled) { 49 | this.scrolled = false 50 | } 51 | } 52 | 53 | /** 跳转到「登录页」 */ 54 | goToLoginPage() { 55 | this.router.navigate(['/login']) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/app/todo/todo-home/todo-home.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 44 | 45 |
2
46 | 47 |
3
48 |
49 | -------------------------------------------------------------------------------- /src/app/home/home.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 |
6 |
7 |
小鸣助手,成为你的随身管家
8 |
让生活更简单一些
9 |
免费使用
10 |
11 |
12 | 13 |
14 |
15 |
16 | 17 | 18 |
19 |
20 |
随时随地,在各种设备上使用
21 |
22 |
23 | 24 |
{{ item.desc }}
25 |
26 |
27 |
28 |
29 | 30 | 31 |
32 |
33 |
20万+用户正在使用小鸣助手
34 |
35 |
36 | 37 |
{{ item.name }}
38 |
{{ item.desc }}
39 |
{{ item.content }}
40 |
41 |
42 |
43 |
44 | 45 |
46 |
47 |
现在使用小鸣助手,开启智慧高效生活
48 |
免费使用
49 |
50 |
51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "life-helper-web", 3 | "version": "2.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve --open", 7 | "build": "ng build --configuration production", 8 | "watch": "ng build --watch --configuration production", 9 | "lite-server": "lite-server --baseDir=dist/life-helper-web/browser", 10 | "test": "ng test" 11 | }, 12 | "private": true, 13 | "dependencies": { 14 | "@angular/animations": "^17.0.0", 15 | "@angular/cdk": "17.0.1", 16 | "@angular/common": "^17.0.0", 17 | "@angular/compiler": "^17.0.0", 18 | "@angular/core": "^17.0.0", 19 | "@angular/forms": "^17.0.0", 20 | "@angular/material": "17.0.1", 21 | "@angular/platform-browser": "^17.0.0", 22 | "@angular/platform-browser-dynamic": "^17.0.0", 23 | "@angular/router": "^17.0.0", 24 | "crypto-js": "^4.2.0", 25 | "loglevel": "^1.8.1", 26 | "rxjs": "~7.8.0", 27 | "tslib": "^2.3.0", 28 | "zone.js": "~0.14.2" 29 | }, 30 | "devDependencies": { 31 | "@angular-devkit/build-angular": "^17.0.1", 32 | "@angular/cli": "^17.0.1", 33 | "@angular/compiler-cli": "^17.0.0", 34 | "@types/crypto-js": "^4.2.1", 35 | "@types/jasmine": "~5.1.0", 36 | "@typescript-eslint/eslint-plugin": "^6.11.0", 37 | "@typescript-eslint/parser": "^6.11.0", 38 | "eslint": "^8.54.0", 39 | "jasmine-core": "~5.1.0", 40 | "karma": "~6.4.0", 41 | "karma-chrome-launcher": "~3.2.0", 42 | "karma-coverage": "~2.2.0", 43 | "karma-jasmine": "~5.1.0", 44 | "karma-jasmine-html-reporter": "~2.1.0", 45 | "lite-server": "^2.6.1", 46 | "prettier": "^3.1.0", 47 | "stylelint": "^15.11.0", 48 | "stylelint-config-recess-order": "^4.3.0", 49 | "stylelint-config-standard-scss": "^11.1.0", 50 | "typescript": "~5.2.2" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/app/login/login.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@angular/common/http' 2 | import { Injectable } from '@angular/core' 3 | import { Router } from '@angular/router' 4 | import { tap } from 'rxjs' 5 | import { AuthService, IdentityCertificate } from '../common/services/auth.service' 6 | 7 | /** 8 | * 登录服务 9 | * 10 | * @since 1.9.0 11 | */ 12 | @Injectable({ providedIn: 'root' }) 13 | export class LoginService { 14 | constructor( 15 | private http: HttpClient, 16 | private authService: AuthService, 17 | private router: Router 18 | ) {} 19 | 20 | /** 21 | * 获取用于「扫码登录」的小程序码信息 22 | */ 23 | getQrCodeTicket() { 24 | return this.http.get('/login/qrcode') 25 | } 26 | 27 | /** 28 | * 检查登录状态 29 | * 30 | * @param id 扫码登录凭据 ID 31 | */ 32 | check(id: string) { 33 | return this.http.post('/login/qrcode', { id }).pipe( 34 | tap((data: QrCodeLoginResult) => { 35 | if (data.logined && data.securityToken) { 36 | this.authService.saveCertificate(data.securityToken) 37 | 38 | // 目前跳转到用户信息页 39 | this.router.navigateByUrl('/user/info') 40 | } 41 | }) 42 | ) 43 | } 44 | } 45 | 46 | /** 47 | * 二维码(小程序码)登录凭据 48 | * 49 | * @since 2.0.0 50 | * @date 2023.05.23 51 | */ 52 | export interface QrCodeTicket { 53 | /** 凭据 ID(注意是字符串不是数字) */ 54 | id: string 55 | /** 要扫码的图片资源的 URL 地址 */ 56 | url: string 57 | } 58 | 59 | /** 60 | * 扫码登录结果 61 | * 62 | * @since 2.0.0 63 | * @date 2023.05.23 64 | */ 65 | export interface QrCodeLoginResult { 66 | /** 是否已扫码,该状态仅用于页面展示 */ 67 | scanned: boolean 68 | /** 是否已登录 */ 69 | logined: boolean 70 | /** 71 | * 登录凭证 72 | * 73 | * ### 备注 74 | * 仅当 `logined` 字段为 `true` 时,当前字段有值 75 | */ 76 | securityToken: IdentityCertificate 77 | } 78 | -------------------------------------------------------------------------------- /src/app/home/home-data.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@angular/core' 2 | 3 | /** 用户评价模块列表项 */ 4 | export interface Customer { 5 | /** 头像 URL */ 6 | avatarUrl: string 7 | /** 姓名 */ 8 | name: string 9 | /** 用户描述 */ 10 | desc: string 11 | /** 评价内容 */ 12 | content: string 13 | } 14 | 15 | /** 客户端设备信息 */ 16 | export interface DeviceInfo { 17 | /** 类型 */ 18 | type: string 19 | /** 图片的 URL 地址 */ 20 | imageUrl: string 21 | /** 描述说明 */ 22 | desc: string 23 | } 24 | 25 | /** 26 | * 首页数据服务 27 | * 28 | * ### 说明 29 | * 实际上,这个服务类不需要,直接将数据放在 `HomeComponent` 类中即可,单独封装这个类的目的是: 30 | * 让视图组件的代码更单纯,同时也方便后续拓展。 31 | */ 32 | @Injectable({providedIn: 'root'}) 33 | export class HomeDataService { 34 | /** 用户评价列表 */ 35 | public customers: Customer[] = [ 36 | { 37 | avatarUrl: 'https://res.lifehelper.com.cn/avatar/236acfb5ce294a0ab3c0dd56b3739598', 38 | name: '会**鱼', 39 | desc: '3年老用户', 40 | content: 41 | '我是从「小鸣助手」上线的时候就开始用了,总体来说,还是很好用的,有很多功能蛮实用的,在「小鸣助手」上查天气比其他的 APP 都要更准。', 42 | }, 43 | { 44 | avatarUrl: 'https://res.lifehelper.com.cn/avatar/2cc99f099606400dbf4f6859314369fd', 45 | name: '小**白', 46 | desc: '2年老用户', 47 | content: 48 | '用了「小鸣助手」2年了,几乎每天都会登录上去看一下,因为我把纪念日都记在上面了。我觉得最大的优点就是免费吧,其他软件的同类功能要不是收费的,要不就是有广告,就「小鸣助手」的界面最清爽好用。', 49 | }, 50 | { 51 | avatarUrl: 'https://res.lifehelper.com.cn/avatar/33b01afea08e4b228d2287dfeafa0a1b', 52 | name: '神**蛙', 53 | desc: '2年老用户', 54 | content: '「小鸣助手」最大的优点就是手机上和电脑上都能用,日常查看就用微信小程序,要打字多的就用网页打开用。', 55 | }, 56 | ] 57 | 58 | /** 客户端设备信息列表 */ 59 | public devices: DeviceInfo[] = [ 60 | { 61 | type: 'miniprogram', 62 | imageUrl: 'https://static.lifehelper.com.cn/web/device01.png', 63 | desc: '使用小程序', 64 | }, 65 | { 66 | type: 'web', 67 | imageUrl: 'https://static.lifehelper.com.cn/web/device03.png', 68 | desc: '使用网页版', 69 | }, 70 | { 71 | type: 'pc', 72 | imageUrl: 'https://static.lifehelper.com.cn/web/device02.png', 73 | desc: '下载桌面端', 74 | }, 75 | ] 76 | } 77 | -------------------------------------------------------------------------------- /src/app/login/login.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core' 2 | import { Router } from '@angular/router' 3 | import { Subscription, interval, mergeMap } from 'rxjs' 4 | import { LoginService } from './login.service' 5 | import { MatIconModule } from '@angular/material/icon' 6 | 7 | @Component({ 8 | selector: 'app-login', 9 | standalone: true, 10 | imports: [MatIconModule], 11 | templateUrl: './login.component.html', 12 | styleUrls: ['./login.component.scss'], 13 | }) 14 | export class LoginComponent implements OnInit { 15 | /** 扫码登录票据 ID */ 16 | id = '' 17 | 18 | /** 小程序码图片的 URL 地址 */ 19 | url = '' 20 | 21 | /** 是否已扫码 */ 22 | scanned = false 23 | 24 | /** 是否已失效 */ 25 | invalid = false 26 | 27 | ob1?: Subscription 28 | ob2?: Subscription 29 | 30 | constructor( 31 | private loginService: LoginService, 32 | private router: Router 33 | ) {} 34 | 35 | ngOnInit(): void { 36 | this.init() 37 | } 38 | 39 | /** 初始化页面数据 */ 40 | private initPageData() { 41 | this.id = '' 42 | this.url = '' 43 | this.scanned = false 44 | this.invalid = false 45 | 46 | if (this.ob1) { 47 | this.ob1.unsubscribe() 48 | this.ob1 = undefined 49 | } 50 | 51 | if (this.ob2) { 52 | this.ob2.unsubscribe() 53 | this.ob2 = undefined 54 | } 55 | } 56 | 57 | /** 页面初始化方法 */ 58 | private init(): void { 59 | this.initPageData() 60 | 61 | this.ob1 = this.loginService.getQrCodeTicket().subscribe((data) => { 62 | this.id = data.id 63 | this.url = data.url 64 | 65 | this.ob2 = interval(1000) 66 | .pipe( 67 | mergeMap(() => { 68 | return this.loginService.check(this.id) 69 | }) 70 | ) 71 | .subscribe((data) => { 72 | if (data.scanned) { 73 | this.scanned = true 74 | } 75 | if (data.logined) { 76 | // 登录成功 77 | this.ob2?.unsubscribe() 78 | console.log('登录成功') 79 | this.router.navigate(['user', 'info']) 80 | } 81 | }) 82 | }) 83 | } 84 | 85 | /** 「刷新」操作 */ 86 | public refresh() { 87 | this.init() 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | 3 | html, 4 | body { 5 | height: 100%; 6 | font-family: -apple-system-font, BlinkMacSystemFont, Roboto, 'Helvetica Neue', 'PingFang SC', 'Hiragino Sans GB', 7 | 'Microsoft YaHei UI', 'Microsoft YaHei', Arial, sans-serif; 8 | font-size: 14px; 9 | font-style: normal; 10 | font-weight: 400; 11 | line-height: 1.4706; 12 | color: var(--ming-text-title); 13 | letter-spacing: -0.022em; 14 | background-color: #fff; 15 | } 16 | 17 | body { 18 | margin: 0; 19 | font-family: Roboto, 'Helvetica Neue', sans-serif; 20 | } 21 | 22 | // 全局变量 23 | // 所有的变量均以 `--ming-` 前缀作为开头,避免在引入第三方变量时导致冲突 24 | :root { 25 | // ================================== 常规颜色 ================================== 26 | // 红色 27 | --ming-red: #fa5151; 28 | 29 | // 橙色 30 | --ming-orange: #fa9d3b; 31 | 32 | // 黄色 33 | --ming-yellow: #ffc300; 34 | 35 | // 绿色 36 | --ming-green: #00c06c; 37 | 38 | // 浅绿色 39 | --ming-lightgreen: #95ec69; 40 | 41 | // 蓝色 42 | --ming-blue: #10aeff; 43 | 44 | // 蓝紫色 45 | --ming-indigo: #1485ee; 46 | 47 | // 紫色 48 | --ming-purple: #6467f0; 49 | 50 | // 白色 51 | --ming-white: #fff; 52 | 53 | // 黑色 54 | --ming-black: #000; 55 | 56 | // ================================== 状态颜色 ================================== 57 | // 成功状态颜色 58 | --ming-success: var(--ming-green); 59 | 60 | // 警告状态颜色 61 | --ming-warning: #ffc300; 62 | 63 | // 错误状态颜色 64 | --ming-error: #fa5151; 65 | 66 | // ================================== 字体颜色 ================================== 67 | // 标题字体颜色 68 | --ming-text-title: #353535; 69 | 70 | // 段落字体颜色 71 | --ming-text-para: #585a5a; 72 | 73 | // 描述性字体颜色 74 | --ming-text-desc: #8a8f8d; 75 | 76 | // 跳转链接字体颜色 77 | --ming-text-link: #576b95; 78 | 79 | // ================================== 背景颜色 ================================== 80 | // 基础背景色(最大块的) 81 | --ming-bg-0: #ededed; 82 | 83 | // 小模块背景色 84 | --ming-bg-1: #f7f7f7; 85 | 86 | // 大模块背景色 87 | --ming-bg-2: #fff; 88 | 89 | // =================================== 其他 =================================== 90 | // 顶部导航栏高度 91 | --ming-header-height: 60px; 92 | } 93 | 94 | // 单元素垂直水平居中(该类放在父元素上) 95 | .one-center { 96 | display: flex; 97 | align-items: center; 98 | justify-content: center; 99 | } 100 | 101 | // 通用页面容器(宽度最大的容器) 102 | .common-container { 103 | width: 1280px; 104 | margin: 0 auto; 105 | } 106 | -------------------------------------------------------------------------------- /src/app/login/login.component.scss: -------------------------------------------------------------------------------- 1 | .page-container { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | height: 100vh; 6 | 7 | // background-color: #fafafa; 8 | background: linear-gradient(rgb(255 255 255) 0%, rgb(255 255 255 / 0%) 100%), 9 | linear-gradient(271.79deg, rgb(0 255 253 / 8%) 1.78%, rgb(236 0 255 / 8%) 50.15%, rgb(255 103 0 / 8%) 98.8%), 10 | rgb(255 255 255); 11 | 12 | .content { 13 | display: flex; 14 | flex-direction: column; 15 | align-items: center; 16 | width: 460px; 17 | height: 600px; 18 | padding: 60px 40px; 19 | background-color: var(--ming-white); 20 | border-radius: 10px; 21 | box-shadow: 22 | 0 4px 8px -4px rgb(0 0 0 / 13%), 23 | 0 6px 16px 0 rgb(0 0 0 / 8%), 24 | 0 12px 24px 16px rgb(0 0 0 / 4%); 25 | 26 | .name { 27 | font-family: serif; 28 | font-size: 32px; 29 | font-weight: 900; 30 | line-height: 36px; 31 | color: var(--ming-text-title); 32 | text-align: center; 33 | letter-spacing: 2px; 34 | cursor: pointer; 35 | } 36 | 37 | .desc { 38 | margin-top: 8px; 39 | font-size: 14px; 40 | color: var(--ming-text-desc); 41 | } 42 | 43 | .tip { 44 | margin-top: 80px; 45 | font-size: 16px; 46 | color: var(--ming-text-para); 47 | } 48 | 49 | .qrcode-area { 50 | position: relative; 51 | padding: 10px; 52 | margin-top: 20px; 53 | border: 1px solid #ddd; 54 | border-radius: 4px; 55 | 56 | .qrcode { 57 | width: 180px; 58 | height: 180px; 59 | } 60 | 61 | .scanned, 62 | .invalid { 63 | position: absolute; 64 | top: 0; 65 | left: 0; 66 | z-index: 99; 67 | display: flex; 68 | flex-direction: column; 69 | align-items: center; 70 | justify-content: center; 71 | width: 200px; 72 | height: 200px; 73 | backdrop-filter: blur(18px); 74 | 75 | .material-icons { 76 | font-size: 70px; 77 | color: var(--ming-success); 78 | } 79 | 80 | .text { 81 | margin-top: 20px; 82 | font-size: 14px; 83 | color: var(--ming-text-title); 84 | } 85 | } 86 | 87 | .invalid { 88 | .material-icons { 89 | color: var(--ming-error); 90 | cursor: pointer; 91 | } 92 | } 93 | } 94 | 95 | .refresh { 96 | display: flex; 97 | align-items: center; 98 | justify-content: center; 99 | margin-top: 20px; 100 | 101 | &:hover { 102 | color: var(--ming-text-link); 103 | cursor: pointer; 104 | } 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "cli": { 5 | "packageManager": "pnpm", 6 | "analytics": false 7 | }, 8 | "newProjectRoot": "projects", 9 | "projects": { 10 | "life-helper-web": { 11 | "projectType": "application", 12 | "schematics": { 13 | "@schematics/angular:component": { 14 | "style": "scss", 15 | "skipTests": true 16 | }, 17 | "@schematics/angular:class": { 18 | "skipTests": true 19 | }, 20 | "@schematics/angular:directive": { 21 | "skipTests": true 22 | }, 23 | "@schematics/angular:guard": { 24 | "skipTests": true 25 | }, 26 | "@schematics/angular:interceptor": { 27 | "skipTests": true 28 | }, 29 | "@schematics/angular:pipe": { 30 | "skipTests": true 31 | }, 32 | "@schematics/angular:resolver": { 33 | "skipTests": true 34 | }, 35 | "@schematics/angular:service": { 36 | "skipTests": true 37 | } 38 | }, 39 | "root": "", 40 | "sourceRoot": "src", 41 | "prefix": "app", 42 | "architect": { 43 | "build": { 44 | "builder": "@angular-devkit/build-angular:application", 45 | "options": { 46 | "outputPath": "dist/life-helper-web", 47 | "index": "src/index.html", 48 | "browser": "src/main.ts", 49 | "polyfills": ["zone.js"], 50 | "tsConfig": "tsconfig.app.json", 51 | "inlineStyleLanguage": "scss", 52 | "assets": ["src/favicon.ico", "src/assets"], 53 | "styles": ["@angular/material/prebuilt-themes/indigo-pink.css", "src/styles.scss"], 54 | "scripts": [] 55 | }, 56 | "configurations": { 57 | "production": { 58 | "fileReplacements": [ 59 | { 60 | "replace": "src/environments/environment.ts", 61 | "with": "src/environments/environment.prod.ts" 62 | } 63 | ], 64 | "budgets": [ 65 | { 66 | "type": "initial", 67 | "maximumWarning": "500kb", 68 | "maximumError": "1mb" 69 | }, 70 | { 71 | "type": "anyComponentStyle", 72 | "maximumWarning": "2kb", 73 | "maximumError": "4kb" 74 | } 75 | ], 76 | "outputHashing": "all" 77 | }, 78 | "development": { 79 | "optimization": false, 80 | "extractLicenses": false, 81 | "sourceMap": true 82 | } 83 | }, 84 | "defaultConfiguration": "production" 85 | }, 86 | "serve": { 87 | "builder": "@angular-devkit/build-angular:dev-server", 88 | "configurations": { 89 | "production": { 90 | "buildTarget": "life-helper-web:build:production" 91 | }, 92 | "development": { 93 | "buildTarget": "life-helper-web:build:development" 94 | } 95 | }, 96 | "defaultConfiguration": "development" 97 | }, 98 | "extract-i18n": { 99 | "builder": "@angular-devkit/build-angular:extract-i18n", 100 | "options": { 101 | "buildTarget": "life-helper-web:build" 102 | } 103 | }, 104 | "test": { 105 | "builder": "@angular-devkit/build-angular:karma", 106 | "options": { 107 | "polyfills": ["zone.js", "zone.js/testing"], 108 | "tsConfig": "tsconfig.spec.json", 109 | "inlineStyleLanguage": "scss", 110 | "assets": ["src/favicon.ico", "src/assets"], 111 | "styles": ["@angular/material/prebuilt-themes/indigo-pink.css", "src/styles.scss"], 112 | "scripts": [] 113 | } 114 | } 115 | } 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |
3 | 小鸣助手 Logo 4 |
5 |

小鸣助手

6 |
让生活更简单一些
7 |
8 | 9 | ## 🤓 项目介绍 10 | 11 | 「小鸣助手」是一个生活服务类小程序,主要为用户的日常生活提供一些便捷工具,例如天气查询、时间规划、生活记录等。目前该小程序已稳定运行近 5 年,为近 10 万用户提供了生活帮助。 12 | 13 | 读者可直接扫描以下小程序码进行体验: 14 | 15 | ![image](https://static.lifehelper.com.cn/static/project/qrcode.jpg) 16 | 17 | ## 💡 项目特点 18 | 19 | 1. 线上正式运行的项目,不是 demo,历经 5 年,久经用户考验。 20 | 2. 开发尽量遵照业界最佳实践,可作为学习样板。 21 | 3. 跟随版本更新,包含 Java、Spring Boot 等,尽量使用**最新稳定版**,保持技术栈不落后。 22 | 23 | ## 🍱 源码仓库 24 | 25 | 笔者在开发项目时,遵照了业界最佳实践,读者可通过研读项目源码来学习相关技术栈。按照功能分类,将项目拆分为了 4 个代码仓库,分别为: 26 | 27 | | 仓库 | 定位 | 技术栈 | 28 | | --------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------ | 29 | | [life-helper-server](https://github.com/inlym/life-helper-server) | 服务端 | `Spring Boot` + `Spring Security` + `JWT` + `MyBatis` + `MySQL` + `Redis` + `Docker` | 30 | | [life-helper-backend](https://github.com/inlym/life-helper-backend) | 服务端 | `Node.js` + `Nest.js` + `TypeScript` + `Typeorm` + `MySQL` + `Redis` + `Docker` | 31 | | [life-helper-miniprogram](https://github.com/inlym/life-helper-miniprogram) | 小程序端 | `TypeScript` + `Scss` | 32 | | [life-helper-web](https://github.com/inlym/life-helper-web) | Web 端 | `Angular` + `TypeScript` + `Scss` + `RxJS` + `Webpack` | 33 | 34 | ## 🗂️ 目录结构 35 | 36 | 当前项目([life-helper-server](https://github.com/inlym/life-helper-server))是一个标准的 Spring Boot 项目,几乎遵照了所有的 Spring Boot 37 | 最佳实践(至少笔者认为自己执行了最严格的标准)。 38 | 39 | 关于项目的目录结构,一般有 2 种常见的思路: 40 | 41 | (1)方案一:以定位划分。这种方案的核心点在于以“代码”的角度,将同功能的代码文件放在一起,例如在电商项目中,商品、订单模块都有控制器,那么所有的控制器文件都放在 `controllers` 42 | 目录下,所有的服务类都放在 `services` 目录下。 43 | 44 | (2)方案二:以功能划分。这种方案的核心点在于以“功能”的角度,将同业务模块的代码文件放在一起,例如不管是控制器还是服务类,只要是商品模块的代码文件,都放到 `goods` 45 | 目录下。 46 | 47 | 笔者在项目实践中,采用的是“**方案二**”,笔者认为在大型项目中,方案二更容易维护。以下是当前项目([life-helper-server](https://github.com/inlym/life-helper-server))的目录结构(_todo_)。 48 | 49 | ```markdown 50 | life-helper-server 51 | ├── src/ # 项目核心代码 52 | │ ├── main/ 53 | │ │ ├── java/ # 入口类及程序的开发目录 54 | │ │ └── resources/ # 资源文件目录,主要用于存放静态文件和配置文件 55 | │ │ ├── static/ # 用于存放静态资源,如 CSS 文件、Javascript 文件、图片等 56 | │ │ ├── templates/ # 用于存放模板文件,如 Thymeleaf 模板文件等 57 | │ │ └── application.yml # 用于配置项目运行所需的配置数据 58 | │ └── test/ # 单元测试程序目录 59 | ├── .editorconfig # `EditorConfig` 插件的配置文件,用于控制一致的代码风格 60 | ├── .gitignore # `Git` 的配置文件,用户控制不被 `Git` 跟踪的文件和目录 61 | ├── Dockerfile # Docker 构建文件 62 | ├── pom.xml # 用于配置项目基本信息和项目依赖 63 | └── README.md # 项目介绍文档,用于对外展现项目基本介绍 64 | ``` 65 | 66 | ## 🚀 技术栈 67 | 68 | | 技术栈 | 链接 | 69 | | :-------------: | --------------------------------------------- | 70 | | Spring Boot | | 71 | | Spring Security | | 72 | | Lombok | | 73 | | Maven | | 74 | | MyBatis | | 75 | | Docker | | 76 | | MySQL | | 77 | | Redis | | 78 | | Druid | | 79 | | JWT | | 80 | | Swagger | | 81 | 82 | ## ❓ 常见问题 83 | 84 | | 序号 | 问题 | 85 | | ---- | ------------------------------------------------------------- | 86 | | 1 | [如何启动项目?](https://github.com/inlym/life-helper-server) | 87 | 88 | ## 📞 交流沟通 89 | 90 | 如果你在使用「小鸣助手」小程序过程中,遇到任何问题,或者有任何意见建议,你可以通过以下方式联系我: 91 | 92 | - [x] 邮箱:_inlym@qq.com_ 93 | - [x] 公众号:搜索公众号「**1 鸣的写字台**」或微信号 `iam1ming`。 94 | 95 | ## 📄 许可证 96 | 97 | 本项目使用 [MIT](LICENSE) 许可证。 98 | -------------------------------------------------------------------------------- /src/app/home/home.component.scss: -------------------------------------------------------------------------------- 1 | .one { 2 | height: 700px; 3 | padding-top: var(--ming-header-height); 4 | background-color: #fafbfc; 5 | 6 | .content { 7 | display: flex; 8 | align-items: center; 9 | justify-content: space-around; 10 | height: 100%; 11 | 12 | .left { 13 | .title { 14 | font-size: 40px; 15 | color: #45586c; 16 | } 17 | 18 | .name { 19 | color: var(--ming-green); 20 | } 21 | 22 | .desc { 23 | margin-top: 40px; 24 | font-size: 24px; 25 | color: #8c95a8; 26 | } 27 | 28 | .button { 29 | width: 200px; 30 | height: 50px; 31 | margin-top: 100px; 32 | font-size: 16px; 33 | line-height: 50px; 34 | color: var(--ming-white); 35 | text-align: center; 36 | background-color: var(--ming-green); 37 | border-radius: 50px; 38 | } 39 | } 40 | 41 | .right { 42 | .img { 43 | width: 700px; 44 | } 45 | } 46 | } 47 | } 48 | 49 | .two { 50 | height: 360px; 51 | background-color: var(--ming-purple); 52 | 53 | .content { 54 | display: flex; 55 | flex-direction: column; 56 | align-items: center; 57 | height: 100%; 58 | padding-top: 80px; 59 | 60 | .text { 61 | height: 100px; 62 | font-size: 50px; 63 | font-weight: bold; 64 | line-height: 100px; 65 | color: var(--ming-white); 66 | } 67 | 68 | .button { 69 | width: 200px; 70 | height: 60px; 71 | margin-top: 40px; 72 | font-size: 20px; 73 | line-height: 60px; 74 | color: var(--ming-purple); 75 | text-align: center; 76 | background-color: var(--ming-bg-2); 77 | border-radius: 60px; 78 | transition: all 0.2s linear; 79 | } 80 | 81 | .button:hover { 82 | cursor: pointer; 83 | background-color: var(--ming-bg-1); 84 | transform: scale(1.03); 85 | } 86 | } 87 | } 88 | 89 | // 用户评价 90 | .customer { 91 | height: 650px; 92 | padding: 100px 0 0; 93 | background-color: #f8f6f6; 94 | 95 | .title { 96 | font-size: 40px; 97 | text-align: center; 98 | } 99 | 100 | .items { 101 | display: flex; 102 | justify-content: space-between; 103 | margin-top: 100px; 104 | 105 | .item { 106 | display: flex; 107 | flex-direction: column; 108 | align-items: center; 109 | width: 420px; 110 | height: 360px; 111 | padding: 32px 15px 0; 112 | margin: 0 20px; 113 | background-color: var(--ming-bg-2); 114 | border: 1px solid #e9e9e9; 115 | border-radius: 10px; 116 | 117 | .avatar { 118 | width: 90px; 119 | border: 2px solid #fff; 120 | border-radius: 50%; 121 | box-shadow: 0 4px 8px rgb(0 0 0 / 10%); 122 | } 123 | 124 | .name { 125 | margin-top: 20px; 126 | font-size: 16px; 127 | } 128 | 129 | .desc { 130 | margin-top: 6px; 131 | color: var(--ming-text-desc); 132 | } 133 | 134 | .cont { 135 | padding: 0 20px; 136 | margin-top: 20px; 137 | font-size: 16px; 138 | line-height: 26px; 139 | } 140 | } 141 | } 142 | } 143 | 144 | .device { 145 | height: 540px; 146 | padding: 100px 0 0; 147 | background-color: #eff6fc; 148 | 149 | .content { 150 | .title { 151 | font-size: 40px; 152 | text-align: center; 153 | } 154 | 155 | .items { 156 | display: flex; 157 | align-items: center; 158 | justify-content: space-between; 159 | width: 100%; 160 | margin-top: 90px; 161 | 162 | .item { 163 | display: flex; 164 | flex-direction: column; 165 | transition: transform 0.3s ease-in-out; 166 | 167 | &:hover { 168 | transform: translateY(-6px); 169 | } 170 | 171 | .img { 172 | width: 350px; 173 | } 174 | 175 | .desc { 176 | height: 60px; 177 | font-size: 20px; 178 | line-height: 60px; 179 | color: var(--ming-text-link); 180 | text-align: center; 181 | background-color: var(--ming-bg-2); 182 | border-radius: 0 0 40px 40px; 183 | } 184 | } 185 | } 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/app/common/interceptors/signature.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { HttpHandlerFn, HttpHeaders, HttpInterceptorFn, HttpParams, HttpRequest } from '@angular/common/http' 2 | import { environment } from '../../../environments/environment' 3 | import { HmacSHA256, MD5, enc } from 'crypto-js' 4 | 5 | const { key, secret } = environment.apigateway 6 | 7 | /** 8 | * 阿里云 API 网关签名拦截器 9 | * 10 | * @see https://help.aliyun.com/zh/api-gateway/user-guide/use-digest-authentication-to-call-an-api 11 | * 12 | * @since 2.0.0 13 | * @date 2023/11/21 14 | */ 15 | export const signatureInterceptor: HttpInterceptorFn = (req, next) => { 16 | const client = new SignatureClient(key, secret, false) 17 | return client.intercept(req, next) 18 | } 19 | 20 | /** 封装签名方法 */ 21 | export class SignatureClient { 22 | constructor( 23 | private key: string, 24 | private secret: string, 25 | private debug: boolean 26 | ) {} 27 | 28 | intercept(request: HttpRequest, next: HttpHandlerFn) { 29 | const paramsNew = this.sortParams(request.params) 30 | 31 | let headersNew: HttpHeaders = this.standardizeHeaders(request.headers) 32 | headersNew = this.addBasicHeaders(headersNew) 33 | 34 | // 存在 `body` 则添加 `content-md5` 请求头 35 | if (request.body) { 36 | headersNew = headersNew.set('content-md5', MD5(JSON.stringify(request.body)).toString(enc.Base64)) 37 | } 38 | 39 | const signHeaderKeys = this.getSignHeaderKeys(headersNew) 40 | headersNew = headersNew.set('x-ca-signature-headers', signHeaderKeys.join(',')) 41 | 42 | const pathAndParams = this.getPathAndParams(request.clone({ params: paramsNew }).urlWithParams) 43 | const signedHeadersString = this.generateSignedHeadersString(signHeaderKeys, headersNew) 44 | const stringToSign = this.buildStringToSign(request.method, headersNew, signedHeadersString, pathAndParams) 45 | 46 | headersNew = headersNew.set('x-ca-signature', HmacSHA256(stringToSign, this.secret).toString(enc.Base64)) 47 | 48 | if (this.debug) { 49 | console.info(`当前签名字符串:\`${stringToSign.replace(/\n/g, '#')}\``) 50 | } 51 | 52 | return next(request.clone({ headers: headersNew, params: paramsNew })) 53 | } 54 | 55 | /** 56 | * 标准化请求头 57 | * 58 | * ## 标准化流程 59 | * 1. 将请求头字段名变为小写 60 | * 2. 去除同名字段 61 | * 3. 去除值为空的字段 62 | * 63 | * @param headers 请求头 64 | */ 65 | private standardizeHeaders(headers: HttpHeaders): HttpHeaders { 66 | return headers.keys().reduce((headersNew: HttpHeaders, key: string) => { 67 | if (!headersNew.getAll(key) || !headersNew.get(key)) { 68 | return headersNew.delete(key) 69 | } else { 70 | return headersNew.set(key.toLowerCase(), headersNew.getAll(key)!) 71 | } 72 | }, headers) 73 | } 74 | 75 | /** 76 | * 添加常规签名认证需要的请求头 77 | * 78 | * @param headers 请求头 79 | */ 80 | private addBasicHeaders(headers: HttpHeaders): HttpHeaders { 81 | return headers 82 | .set('x-ca-key', this.key) 83 | .set('x-ca-timestamp', Date.now().toString()) 84 | .set('accept', headers.getAll('accept') ? headers.getAll('accept')! : '*/*') 85 | .set('content-type', headers.getAll('content-type') ? headers.getAll('content-type')! : 'application/json') 86 | .set('x-ca-nonce', MD5((Date.now() + Math.random()).toString()).toString(enc.Hex)) 87 | } 88 | 89 | /** 90 | * 获取参与签名的请求头字段名 91 | */ 92 | private getSignHeaderKeys(headers: HttpHeaders): string[] { 93 | /** 不参与 Header 签名的请求头 */ 94 | const EXCLUDE_SIGN_HEADERS = [ 95 | 'x-ca-signature', 96 | 'x-xa-signature-headers', 97 | 'accept', 98 | 'content-md5', 99 | 'content-type', 100 | 'date', 101 | ] 102 | 103 | return headers 104 | .keys() 105 | .filter((key) => !EXCLUDE_SIGN_HEADERS.includes(key)) 106 | .sort() 107 | } 108 | 109 | /** 110 | * 生成请求头的签名字符串 111 | */ 112 | private generateSignedHeadersString(signHeaderKeys: string[], headers: HttpHeaders): string { 113 | return signHeaderKeys.map((key) => key + ':' + headers.getAll(key)?.join(',')).join('\n') 114 | } 115 | 116 | /** 117 | * 获取 [路径 + 查询字符串] 部分 118 | */ 119 | private getPathAndParams(urlWithParams: string): string { 120 | const raw: string = urlWithParams.replace('https://', '').replace('http://', '') 121 | return raw.substring(raw.indexOf('/')) || '/' 122 | } 123 | 124 | /** 125 | * 对查询字符串重排序(字典排序) 126 | */ 127 | private sortParams(params: HttpParams): HttpParams { 128 | return params 129 | .keys() 130 | .sort() 131 | .reduce((paramsNew: HttpParams, key: string) => { 132 | return paramsNew.set(key, params.get(key) || '') 133 | }, new HttpParams()) 134 | } 135 | 136 | /** 137 | * 生成用于签名的字符串 138 | */ 139 | private buildStringToSign( 140 | method: string, 141 | headers: HttpHeaders, 142 | signedHeadersString: string, 143 | pathAndParams: string 144 | ): string { 145 | const LF = '\n' 146 | const list: string[] = [method.toUpperCase(), LF] 147 | 148 | const keys: string[] = ['accept', 'content-md5', 'content-type', 'date'] 149 | keys.forEach((key) => { 150 | if (headers.getAll(key)) { 151 | list.push(headers.getAll(key)!.join(',')!) 152 | } 153 | list.push(LF) 154 | }) 155 | 156 | if (signedHeadersString) { 157 | list.push(signedHeadersString, LF) 158 | } 159 | 160 | if (pathAndParams) { 161 | list.push(pathAndParams) 162 | } 163 | 164 | return list.join('') 165 | } 166 | } 167 | --------------------------------------------------------------------------------