快乐的日子就会忘记写文章,痛苦的生活又让我想起了这里。
用当前最新版的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;
}
画廊页也好了
接下来就剩一些业务上的细节调整了。