快樂的日子就會忘記寫文章夺颤,痛苦的生活又讓我想起了這里。
用當前最新版的Angular19重寫一個舊項目。
一被去、環(huán)境準備
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
二坯墨、創(chuàng)建項目
項目功能比較簡單,只有一個登錄病往,一個照片展示畅蹂,但是按照習慣,我還是分成了兩個模塊
1荣恐、CLI生成基礎框架
ng new project-name
這里就先一路默認選項了
安裝好之后進入項目根目錄液斜,啟動項目
ng serve
一切正常
2累贤、創(chuàng)建兩個懶加載模塊:一個認證模塊,一個主布局模塊少漆。加上 --routing 參數(shù)臼膏,可同時創(chuàng)建對應的路由模塊
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 文件渗磅,導航到上面創(chuàng)建好的兩個模塊中
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检访、創(chuàng)建登錄頁始鱼,畫廊頁
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>
調(diào)整css会烙,讓內(nèi)容居中展示
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模塊
默認路由到畫廊頁,如果業(yè)務上有例如訂單筒捺、商品柏腻、人員等等頁面,也在這里配置系吭。一個網(wǎng)站通常會有相同的頁頭五嫂、頁腳、側滑導航菜單等功能肯尺,都放在layout里沃缘。只把中心位置留給具體的業(yè)務組件。(但是當前項目沒有這些需求蟆盹,略過)
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孩灯、創(chuàng)建環(huán)境配置
ng g environments
配置好開發(fā)環(huán)境和正式環(huán)境的接口地址
environments/environment.development.ts
export const environment = {
serverAddress: 'http://127.0.0.1:5001',//這是我本地nestjs服務的地址
};
environments/environment.ts
export const environment = {
serverAddress: 'https://正式地址',
};
到此,主要結構有了逾滥,再來就是補充具體功能
三峰档、功能實現(xiàn)
1、創(chuàng)建數(shù)據(jù)模型
用戶信息結構
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;
}
登錄參數(shù)結構
ng g interface models/login.dto
export interface LoginDto {
account: string;
password: string;
}
畫廊查詢參數(shù)結構
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寨昙、創(chuàng)建常量
用來配置服務端的接口地址
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讥巡、創(chuàng)建服務,用來進行接口調(diào)用舔哪、數(shù)據(jù)存儲等功能
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服務用來存取本地數(shù)據(jù)
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服務用來獲取照片列表
這里先補充一個工具類欢顷,用來處理參數(shù)對象轉字符串
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)}`
);
}
}
尼瑪,剛寫完的內(nèi)容發(fā)布就丟失了W皆椤抬驴!
冷靜一下炼七,重新寫吧
4、創(chuàng)建HTTP請求響應攔截器
當接口調(diào)用前布持,自動注入token豌拙。在接口響應后,如果token失效题暖,則進行續(xù)期或跳轉登錄頁按傅。這里的token有效期很長,略過續(xù)期功能
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);
})
);
};
創(chuàng)建好之后在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枝誊、創(chuàng)建路由守衛(wèi)
如果發(fā)現(xiàn)客戶端沒有存儲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
安裝完需要重啟服務
登錄頁
去官網(wǎng)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;
}
畫廊頁也好了
接下來就剩一些業(yè)務上的細節(jié)調(diào)整了牛柒。