2024-12-26 Angular19创建一个基础项目

快乐的日子就会忘记写文章,痛苦的生活又让我想起了这里。
用当前最新版的Angular19重写一个旧项目。

一、环境准备

1、Nodejs 不低于 v18.19.1

2、NPM镜像设置

npm config set registry  "https://registry.npmmirror.com"

3、安装Angular最新版本

npm install -g @angular/cli@latest

如果存在旧版本,需要先卸载,再安装

npm uninstall -g @angular/cli
npm cache clean --force
npm install -g @angular/cli@latest

查看当前版本

ng version

二、创建项目

项目功能比较简单,只有一个登录,一个照片展示,但是按照习惯,我还是分成了两个模块

1、CLI生成基础框架

ng new project-name

这里就先一路默认选项了
安装好之后进入项目根目录,启动项目

ng serve

一切正常

2、创建两个懒加载模块:一个认证模块,一个主布局模块。加上 --routing 参数,可同时创建对应的路由模块

ng g m modules/auth --routing
ng g c modules/auth
ng g m modules/layout --routing
ng g c modules/layout 

3、配置主路由,修改 app/app.routes.ts 文件,导航到上面创建好的两个模块中

import { Routes } from '@angular/router';

export const routes: Routes = [
  {
    path: 'auth',
    loadChildren: () =>
      import('./modules/auth/auth.module').then((m) => m.AuthModule),
  },
  {
    path: '',
    loadChildren: () =>
      import('./modules/layout/layout.module').then((m) => m.LayoutModule),
  },
];

修改根模板文件 app/app.component.html,路由到指定模块

<router-outlet />

4、创建登录页,画廊页

ng g c pages/login
ng g c pages/gallery

配置路由

auth模块

默认导航到登录页。如果有注册页,也在这里配置,目前没开放注册,因此略过
app/modules/auth/auth-routing.module.ts

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthComponent } from './auth.component';
import { LoginComponent } from '../../pages/login/login.component';

const routes: Routes = [
  {
    path: '',
    component: AuthComponent,
    children: [
      { path: 'login', component: LoginComponent },
      { path: '**', redirectTo: 'login', pathMatch: 'full' },
    ],
  },
];

@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule],
})
export class AuthRoutingModule {}

AuthComponent也需要配置路由标签。先引入RouterOutlet
app/modules/auth/auth.component.ts

import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';

@Component({
  selector: 'app-auth',
  imports: [RouterOutlet],
  templateUrl: './auth.component.html',
  styleUrl: './auth.component.css',
})
export class AuthComponent {}

再修改模板文件
app/modules/auth/auth.component.html

<div class="auth-container">
  <router-outlet />
</div>

调整css,让内容居中展示
app/modules/auth/auth.component.css

.auth-container {
    display: flex;
    justify-content: center;
    align-items: center;
    height: 100vh;
    background-color: rgba(0, 0, 0, 0.7);
}
layout模块

默认路由到画廊页,如果业务上有例如订单、商品、人员等等页面,也在这里配置。一个网站通常会有相同的页头、页脚、侧滑导航菜单等功能,都放在layout里。只把中心位置留给具体的业务组件。(但是当前项目没有这些需求,略过)
app/modules/layout/layout-routing.module.ts

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { LayoutComponent } from './layout.component';
import { GalleryComponent } from '../../pages/gallery/gallery.component';

const routes: Routes = [
  {
    path: '',
    component: LayoutComponent,
    children: [
      {
        path: 'gallery',
        component: GalleryComponent,
      },
      { path: '**', redirectTo: 'gallery', pathMatch: 'full' },
    ],
  },
];

@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule],
})
export class LayoutRoutingModule {}

app/modules/layout/layout.component.ts

import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';

@Component({
  selector: 'app-layout',
  imports: [RouterOutlet],
  templateUrl: './layout.component.html',
  styleUrl: './layout.component.css',
})
export class LayoutComponent {}

app/modules/layout/layout.component.html

<!-- 这里可以放header -->
<p>layout works!</p>
<router-outlet />
<!-- 这里可以放footer -->

5、创建环境配置

ng g environments

配置好开发环境和正式环境的接口地址
environments/environment.development.ts

export const environment = {
    serverAddress: 'http://127.0.0.1:5001',//这是我本地nestjs服务的地址
};

environments/environment.ts

export const environment = {
  serverAddress: 'https://正式地址',
};

到此,主要结构有了,再来就是补充具体功能

三、功能实现

1、创建数据模型

用户信息结构

ng g interface models/user
export interface User {
  id: number;
  account: string;
  nickname: string;
  avatar: string;
  token: string;
}

画廊信息结构

ng g interface models/gallery
export interface Gallery {
  id: number;
  url: string;
  created_at: string;
}

登录参数结构

ng g interface models/login.dto
export interface LoginDto {
  account: string;
  password: string;
}

画廊查询参数结构

ng g interface models/gallery-query.dto
export interface GalleryQueryDto {
  [key: string]: any;
  start_time?: number;
  end_time?: number;
  size?: number;
  page?: number;
}

基础接口返回结构

ng g interface models/response-base.dto
export interface ResponseBaseDto<T> {
  code: number;
  data: T;
  msg: string;
}

分页接口返回结构

ng g interface models/response-page.dto
export interface ResponsePageDto<T> {
  code: number;
  data: Array<T>;
  msg: string;
  total: number;
}

2、创建常量

用来配置服务端的接口地址

ng g class constants/server-url
import { environment } from '../../environments/environment';

export class ServerUrl {
  public static readonly login = `${environment.serverAddress}/exhibition-hall/v1/users/login`;
  public static readonly gallery = `${environment.serverAddress}/exhibition-hall/v1/h5/gallery`;
}

3、创建服务,用来进行接口调用、数据存储等功能

ng g s services/auth
ng g s services/gallery
ng g s services/storage

auth服务用来登录

import { Injectable } from '@angular/core';
import { LoginDto } from '../models/login.dto';
import { HttpClient } from '@angular/common/http';
import { ResponseBaseDto } from '../models/response-base.dto';
import { User } from '../models/user';
import { ServerUrl } from '../constants/server-url';

@Injectable({
  providedIn: 'root',
})
export class AuthService {
  constructor(private readonly http: HttpClient) {}

  login(loginDto: LoginDto) {
    return this.http.post<ResponseBaseDto<User>>(ServerUrl.login, loginDto);
  }
}

storage服务用来存取本地数据

import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root',
})
export class StorageService {
  constructor() {}

  public get<T>(key: string): T | null {
    const data = localStorage.getItem(key);
    if (data === null) {
      return null;
    }
    return JSON.parse(data);
  }

  public set<T>(key: string, value: T): T | null {
    if (value === undefined) {
      return null;
    }
    localStorage.setItem(key, JSON.stringify(value));
    return value;
  }
}

gallery服务用来获取照片列表

这里先补充一个工具类,用来处理参数对象转字符串

ng g class utils
export class Utils {
  public static queryParams2str(
    params: Record<string, number | string | boolean | null>
  ): string {
    const querys: Array<string> = [];
    Object.keys(params).forEach((key) => {
      querys.push(`${key}=${params[key]}`);
    });
    return querys.join('&');
  }
}

然后在gallery服务中使用

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { GalleryQueryDto } from '../models/gallery-query.dto';
import { ResponsePageDto } from '../models/response-page.dto';
import { Gallery } from '../models/gallery';
import { ServerUrl } from '../constants/server-url';
import { Utils } from '../utils';

@Injectable({
  providedIn: 'root',
})
export class GalleryService {
  constructor(private readonly http: HttpClient) {}

  list(galleryQueryDto: GalleryQueryDto) {
    return this.http.get<ResponsePageDto<Gallery>>(
      `${ServerUrl.gallery}?${Utils.queryParams2str(galleryQueryDto)}`
    );
  }
}

尼玛,刚写完的内容发布就丢失了!!

冷静一下,重新写吧

4、创建HTTP请求响应拦截器

当接口调用前,自动注入token。在接口响应后,如果token失效,则进行续期或跳转登录页。这里的token有效期很长,略过续期功能

ng g interceptor utils/auth
import { HttpInterceptorFn, HttpStatusCode } from '@angular/common/http';
import { inject } from '@angular/core';
import { StorageService } from '../services/storage.service';
import { User } from '../models/user';
import { StorageKey } from '../constants/storage-key';
import { Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const user = inject(StorageService).get<User>(StorageKey.USER);
  const router = inject(Router);
  const token = 'Bearer ' + user?.token;
  const newReq = req.clone({
    headers: req.headers.append('Authorization', token),
  });

  return next(newReq).pipe(
    catchError((err) => {
      if (err.status === HttpStatusCode.Unauthorized) {
        router.navigate(['/auth/login']);
      }
      return throwError(() => err);
    })
  );
};

创建好之后在layout模块中使用拦截器,auth模块不需要
layout.module.ts

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';

import { LayoutRoutingModule } from './layout-routing.module';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { authInterceptor } from '../../utils/auth.interceptor';

@NgModule({
  declarations: [],
  imports: [CommonModule, LayoutRoutingModule],
  providers: [provideHttpClient(withInterceptors([authInterceptor]))],
})
export class LayoutModule {}

但是auth模块也需要使用http请求的,因此在根模块配置一个全局的http服务
app.config.ts

import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';

import { routes } from './app.routes';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import {
  provideHttpClient,
  withInterceptorsFromDi,
} from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }),
    provideRouter(routes),
    provideAnimationsAsync(),
    provideHttpClient(withInterceptorsFromDi()),
  ],
};

这样auth就会使用全局配置的http服务,而layout中会用自己局部的http覆盖全局的

5、创建路由守卫

如果发现客户端没有存储token,直接跳登录页

ng g guard utils/auth 
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { StorageService } from '../services/storage.service';
import { User } from '../models/user';
import { StorageKey } from '../constants/storage-key';

export const authGuard: CanActivateFn = (route, state) => {
  const result = !!inject(StorageService).get<User>(StorageKey.USER)?.token;
  if (!result) {
    inject(Router).navigate(['/auth/login']);
  }
  return result;
};

6、UI

安装官方的UI库 Angular Material

ng add @angular/material

安装完需要重启服务

登录页

去官网cv几个组件,拼一个登录页出来
MatFormFieldModule 表单项
MatInputModule 输入框
MatIconModule 图标
MatButtonModule 登录按钮
MatSnackBar 消息弹窗
login.component.ts

import {
  ChangeDetectionStrategy,
  Component,
  inject,
  signal,
} from '@angular/core';
import {
  FormControl,
  FormGroup,
  FormsModule,
  ReactiveFormsModule,
  Validators,
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { AuthService } from '../../services/auth.service';
import { StorageService } from '../../services/storage.service';
import { Router } from '@angular/router';
import { User } from '../../models/user';
import { StorageKey } from '../../constants/storage-key';
import { MatSnackBar } from '@angular/material/snack-bar';

@Component({
  selector: 'app-login',
  imports: [
    MatFormFieldModule,
    MatInputModule,
    MatButtonModule,
    MatIconModule,
    FormsModule,
    ReactiveFormsModule,
  ],
  templateUrl: './login.component.html',
  styleUrl: './login.component.css',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LoginComponent {
  private snackBar = inject(MatSnackBar);
  hide = signal(true);

  form = new FormGroup({
    account: new FormControl('', [Validators.required]),
    password: new FormControl('', [Validators.required]),
  });

  constructor(
    private authService: AuthService,
    private storageService: StorageService,
    private router: Router
  ) {}

  clickEvent(event: MouseEvent) {
    this.hide.set(!this.hide());
    event.stopPropagation();
    return false;
  }

  onSubmit() {
    if (!this.form.valid) {
      return;
    }
    const { account, password } = this.form.value;
    this.authService
      .login({
        account: account || '',
        password: password || '',
      })
      .subscribe((data) => {
        if (data.code != 0) {
          this.snackBar.open(`${data.code} ${data.msg}`, '', {
            duration: 3000,
            verticalPosition: 'top',
            panelClass: 'snackbar',
          });
        } else {
          this.storageService.set<User>(StorageKey.USER, data.data);
          this.router.navigate(['gallery']);
        }
      });
  }
}

login.component.html

<form class="login-form" (ngSubmit)="onSubmit()">
  <mat-form-field class="login-form-item">
    <mat-label>账号</mat-label>
    <input
      matInput
      placeholder="请输入账号"
      [formControl]="form.controls.account"
      required
    />
  </mat-form-field>

  <mat-form-field class="login-form-item">
    <mat-label>密码</mat-label>
    <input
      matInput
      placeholder="请输入密码"
      [formControl]="form.controls.password"
      [type]="hide() ? 'password' : 'text'"
      autocomplete
      required
    />
    <button
      mat-icon-button
      matSuffix
      (click)="clickEvent($event)"
      [attr.aria-label]="'Hide password'"
      [attr.aria-pressed]="hide()"
    >
      <mat-icon>{{ hide() ? "visibility_off" : "visibility" }}</mat-icon>
    </button>
  </mat-form-field>

  <button class="btn-login" mat-flat-button type="submit">登录</button>
</form>

login.component.css

* {
    --mat-sys-surface-variant: white;
}

.login-form {
    background-color: white;
    border-radius: 16px;
    display: flex;
    flex-direction: column;
    width: 20rem;
    height: 14rem;
    padding: 2rem 3rem;
}

.btn-login {
    margin-top: 1rem;
}

登录页就做好了


登录页

画廊页

使用了开源库ngx-masonry
安装

npm install ngx-masonry masonry-layout

gallery.component.ts

import { Component } from '@angular/core';
import { Gallery } from '../../models/gallery';
import { GalleryService } from '../../services/gallery.service';
import { NgxMasonryModule } from 'ngx-masonry';
import { NgFor, NgIf } from '@angular/common';

@Component({
  selector: 'app-gallery',
  imports: [NgxMasonryModule, NgFor, NgIf],
  templateUrl: './gallery.component.html',
  styleUrl: './gallery.component.css',
})
export class GalleryComponent {
  galleries: Array<Gallery> = [];

  constructor(
    private readonly galleryService: GalleryService
  ) {
    this.refreshData();
  }

  refreshData() {
    this.galleryService
      .list({})
      .subscribe((data) => {
        if (data.code == 0) {
          this.galleries = data.data || [];
        }
      });
  }
}

gallery.component.html

<ngx-masonry>
  <div ngxMasonryItem class="masonry-item" *ngFor="let gallery of galleries">
    <img *ngIf="gallery.url" class="gallery-img" [src]="gallery.url" />
  </div>
</ngx-masonry>

gallery.component.css

.masonry-item {
    width: calc(20% - 0.5rem);
    margin: 0.25rem;
}

.gallery-img {
    max-width: 100%;
    border-radius: 0.5rem;
}

画廊页也好了


画廊页

接下来就剩一些业务上的细节调整了。

至此,搭建完成

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容