完成 微前端拆分
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
|
|
||||||
|
|
||||||
## DDD领域驱动设计的微服务前端案例
|
## DDD领域驱动设计的微服务前端案例
|
||||||
|
|
||||||
> 持续开发中,及时拉取代码,文档还不够完善,先凑合看!!目前只是demo,代码还会进行大规模封装细化和改造!
|
> 持续开发中,及时拉取代码,文档还不够完善,先凑合看!!目前只是demo,代码还会进行大规模封装细化和改造!
|
||||||
|
|||||||
@@ -1 +1,10 @@
|
|||||||
# 临时占位,上传空文件夹,保存文件结构用,后面删除
|
npm install
|
||||||
|
npm run build
|
||||||
|
npm run serve
|
||||||
|
|
||||||
|
|
||||||
|
构建与部署:
|
||||||
|
|
||||||
|
- 在构建生产环境时,先构建主模块,并将其产出(特别是 remoteEntry.js 和对应的资产文件)部署到服务器,并获取其 URL。
|
||||||
|
- 然后,在子模块的 vite.config.ts 中,将 remotes 的 URL 更新为生产环境主模块 remoteEntry.js 的绝对 URL。
|
||||||
|
- 最后再构建子模块。
|
||||||
3
front-end/app-product/src/shared/types/product-federation.d.ts
vendored
Normal file
3
front-end/app-product/src/shared/types/product-federation.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
declare module 'mainApp/productService' {
|
||||||
|
export const product: any;
|
||||||
|
}
|
||||||
@@ -7,10 +7,10 @@
|
|||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite --port 5000 --strictPort",
|
||||||
"build": "run-p type-check \"build-only {@}\" --",
|
"serve": "vite preview --port 5000 --strictPort",
|
||||||
"preview": "vite preview",
|
"build": "vite build",
|
||||||
"build-only": "vite build",
|
"all": "npm run build && npm run serve",
|
||||||
"type-check": "vue-tsc --build"
|
"type-check": "vue-tsc --build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { ProductApplicationService } from "../../domains/product/application/services/ProductApplicationService";
|
// 从商品联邦模块导入领域层资源
|
||||||
import { HttpProductRepository } from "../../domains/product/infr/api/HttpProductRepository";
|
import {
|
||||||
import { ProductRepository } from "../../domains/product/ports/repositories/ProductRepository";
|
ProductApplicationService,
|
||||||
|
HttpProductRepository,
|
||||||
|
ProductRepository
|
||||||
|
} from 'productModule/ProductDomain';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 商品领域的依赖注入配置
|
* 商品领域的依赖注入配置
|
||||||
@@ -21,7 +23,7 @@ import { ProductRepository } from "../../domains/product/ports/repositories/Prod
|
|||||||
* productModule.bindProductRepository = () => new MockProductRepository();
|
* productModule.bindProductRepository = () => new MockProductRepository();
|
||||||
* 这样测试就可以脱离真实 API,使用预设的模拟数据。
|
* 这样测试就可以脱离真实 API,使用预设的模拟数据。
|
||||||
*/
|
*/
|
||||||
export const productModule = {
|
export const product = {
|
||||||
|
|
||||||
// 绑定 仓储抽象接口 到 HTTP具体实现
|
// 绑定 仓储抽象接口 到 HTTP具体实现
|
||||||
bindProductRepository: (): ProductRepository => {
|
bindProductRepository: (): ProductRepository => {
|
||||||
@@ -32,7 +34,7 @@ export const productModule = {
|
|||||||
// 创建应用服务实例
|
// 创建应用服务实例
|
||||||
createProductApplicationService: (): ProductApplicationService => {
|
createProductApplicationService: (): ProductApplicationService => {
|
||||||
return new ProductApplicationService(
|
return new ProductApplicationService(
|
||||||
productModule.bindProductRepository()
|
product.bindProductRepository()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
|
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
|
||||||
import { productRouter } from '@features/product/router'
|
import { productRouter } from 'productModule/ProductRouter'
|
||||||
|
|
||||||
const baseRouter: Array<RouteRecordRaw> = [
|
const baseRouter: Array<RouteRecordRaw> = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import { ErrorText } from "../../domain/enums/ErrorEnums";
|
|
||||||
import { ProductRepository } from "../../ports/repositories/ProductRepository";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 商品应用服务 - 协调领域对象完成业务用例
|
|
||||||
* 应用服务是领域层的外观,封装了领域逻辑的调用
|
|
||||||
*/
|
|
||||||
export class ProductApplicationService {
|
|
||||||
// 依赖注入仓储接口,而不是具体实现
|
|
||||||
constructor(private productRepository: ProductRepository) { }
|
|
||||||
|
|
||||||
async getProductList() {
|
|
||||||
return await this.productRepository.findAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
async getProductInfo(id: number) {
|
|
||||||
if (!id) {
|
|
||||||
throw new Error(ErrorText.IdNotNull)
|
|
||||||
}
|
|
||||||
return await this.productRepository.findById(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
import { ErrorText } from "../enums/ErrorEnums"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 商品实体 - 包含商品的核心属性和行为
|
|
||||||
* 在DDD中,实体具有唯一标识和生命周期
|
|
||||||
*/
|
|
||||||
export class Product {
|
|
||||||
private readonly _id: number;
|
|
||||||
private _name: string;
|
|
||||||
private _description: string;
|
|
||||||
private _price: number;
|
|
||||||
private _imageUrl: string;
|
|
||||||
private _stock: number;
|
|
||||||
private _categoryId: number;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
id: number,
|
|
||||||
name: string,
|
|
||||||
description: string,
|
|
||||||
price: number,
|
|
||||||
imageUrl: string,
|
|
||||||
stock: number,
|
|
||||||
categoryId: number,
|
|
||||||
) {
|
|
||||||
this._id = id;
|
|
||||||
this._name = name;
|
|
||||||
this._description = description;
|
|
||||||
this._price = price;
|
|
||||||
this._imageUrl = imageUrl;
|
|
||||||
this._stock = stock;
|
|
||||||
this._categoryId = categoryId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 实体唯一标识
|
|
||||||
get id(): number {
|
|
||||||
return this._id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get name(): string {
|
|
||||||
return this._name;
|
|
||||||
}
|
|
||||||
public set name(value: string) {
|
|
||||||
this._name = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get description(): string {
|
|
||||||
return this._description;
|
|
||||||
}
|
|
||||||
public set description(value: string) {
|
|
||||||
this._description = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public get price(): number {
|
|
||||||
return this._price;
|
|
||||||
}
|
|
||||||
public set price(value: number) {
|
|
||||||
if (value < 0) {
|
|
||||||
throw new Error(ErrorText.PriceNotNegativein);
|
|
||||||
}
|
|
||||||
this._price = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get imageUrl(): string {
|
|
||||||
return this._imageUrl;
|
|
||||||
}
|
|
||||||
public set imageUrl(value: string) {
|
|
||||||
this._imageUrl = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get stock(): number {
|
|
||||||
return this._stock;
|
|
||||||
}
|
|
||||||
public set stock(value: number) {
|
|
||||||
if (value < 0) {
|
|
||||||
throw new Error(ErrorText.StockNotNegativein)
|
|
||||||
}
|
|
||||||
this._stock = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get categoryId(): number {
|
|
||||||
return this._categoryId;
|
|
||||||
}
|
|
||||||
public set categoryId(value: number) {
|
|
||||||
this._categoryId = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查商品是否有库存
|
|
||||||
*/
|
|
||||||
hasStock() {
|
|
||||||
return this._stock > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 减少商品库存
|
|
||||||
* @param quantity 减少的数量
|
|
||||||
*/
|
|
||||||
reduceStock(quantity: number): void {
|
|
||||||
if (quantity <= 0) {
|
|
||||||
throw new Error(ErrorText.StockIsGreaterThanZero)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._stock < quantity) {
|
|
||||||
throw new Error(ErrorText.LowStock)
|
|
||||||
}
|
|
||||||
|
|
||||||
this._stock -= quantity;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Class类转化为普通json数据
|
|
||||||
*/
|
|
||||||
toDTO(): Record<string, string | number> {
|
|
||||||
return {
|
|
||||||
id: this._id,
|
|
||||||
name: this._name,
|
|
||||||
description: this._description,
|
|
||||||
price: this._price,
|
|
||||||
imageUrl: this._imageUrl,
|
|
||||||
stock: this._stock,
|
|
||||||
categoryId: this._categoryId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从json数据创建Product类实例
|
|
||||||
* 转化为领域实体 Product
|
|
||||||
*/
|
|
||||||
static fromDTO(data: Record<string, any>): Product {
|
|
||||||
return new Product(
|
|
||||||
data.id,
|
|
||||||
data.name,
|
|
||||||
data.description,
|
|
||||||
data.price,
|
|
||||||
data.imageUrl,
|
|
||||||
data.stock,
|
|
||||||
data.categoryId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export enum ErrorText {
|
|
||||||
PriceNotNegativein = "商品 价格 不能为负数",
|
|
||||||
StockNotNegativein = "商品 库存 不能为负数",
|
|
||||||
StockIsGreaterThanZero = "减少的库存数量必须大于0",
|
|
||||||
LowStock = "库存不足",
|
|
||||||
IdNotNull = "商品Id不能为空"
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# 临时占位,上传空文件夹,保存文件结构用,后面删除
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export interface ProductType {
|
|
||||||
id: number,
|
|
||||||
name: string,
|
|
||||||
description: string,
|
|
||||||
price: number,
|
|
||||||
imageUrl: string,
|
|
||||||
stock: number,
|
|
||||||
categoryId: number,
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# 临时占位,上传空文件夹,保存文件结构用,后面删除
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { Product } from "../../domain/entities/Product";
|
|
||||||
import { ProductType } from "../../domain/type/ProductType";
|
|
||||||
import { ProductRepository } from "../../ports/repositories/ProductRepository";
|
|
||||||
import http from "@shared/infra/AxiosInfra";
|
|
||||||
import HttpConfig from "../config/HttpConfig";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HTTP商品仓储实现 - 实现商品数据的HTTP访问
|
|
||||||
* 这是基础设施层,负责具体的数据获取实现
|
|
||||||
*/
|
|
||||||
export class HttpProductRepository implements ProductRepository {
|
|
||||||
|
|
||||||
async findAll(): Promise<Array<ProductType>> {
|
|
||||||
const result: Array<ProductType> = await http.get({ url: HttpConfig.apiUrlList.productsListUrl });
|
|
||||||
const items = result.map((item) => Product.fromDTO(item))
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findById(id: number): Promise<Product> {
|
|
||||||
const result: ProductType = await http.get({
|
|
||||||
url: HttpConfig.apiUrlList.productsInfoUrl,
|
|
||||||
data: { id }
|
|
||||||
});
|
|
||||||
return Product.fromDTO(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
interface apiUrlListType {
|
|
||||||
productsListUrl: string
|
|
||||||
productsInfoUrl: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class HttpConfig {
|
|
||||||
public static apiUrlList: apiUrlListType = {
|
|
||||||
productsListUrl: '/productsList',
|
|
||||||
productsInfoUrl: '/productsInfo',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { Product } from "../../domain/entities/Product"
|
|
||||||
import { ProductType } from "../../domain/type/ProductType"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 商品仓储接口 - 定义商品数据访问的契约
|
|
||||||
* 在DDD中,端口定义了领域层与外部世界的交互方式
|
|
||||||
*/
|
|
||||||
export interface ProductRepository {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取商品列表
|
|
||||||
*/
|
|
||||||
findAll(): Promise<Array<ProductType>>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据ID获取商品详情
|
|
||||||
* @param id 商品ID
|
|
||||||
*/
|
|
||||||
findById(id: number): Promise<Product>
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# 临时占位,上传空文件夹,保存文件结构用,后面删除
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# 临时占位,上传空文件夹,保存文件结构用,后面删除
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# 临时占位,上传空文件夹,保存文件结构用,后面删除
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { RouteRecordRaw } from 'vue-router';
|
|
||||||
|
|
||||||
|
|
||||||
export const productRouter: Array<RouteRecordRaw> = [
|
|
||||||
{
|
|
||||||
path: '/Products',
|
|
||||||
name: 'ProductList',
|
|
||||||
component: () => import('../views/ProductList.tsx'),
|
|
||||||
meta: { title: '商品列表' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/ProductDetail',
|
|
||||||
name: 'ProductDetail',
|
|
||||||
component: () => import('../views/ProductDetail.tsx'),
|
|
||||||
meta: { title: '商品详情' }
|
|
||||||
},
|
|
||||||
]
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { productModule } from '@/di/productModule';
|
|
||||||
import { ProductType } from '@domains/product/domain/type/ProductType';
|
|
||||||
import { defineComponent, onMounted, reactive } from 'vue'
|
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
|
|
||||||
interface storeType {
|
|
||||||
item: ProductType
|
|
||||||
id: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
name: "productModule-ProductDetail",
|
|
||||||
setup() {
|
|
||||||
const route = useRoute();
|
|
||||||
const productService = productModule.createProductApplicationService();
|
|
||||||
|
|
||||||
const state = reactive<storeType>({
|
|
||||||
item: {} as ProductType,
|
|
||||||
id: 0
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (!route.query.id) {
|
|
||||||
throw new Error("缺少id参数");
|
|
||||||
}
|
|
||||||
|
|
||||||
state.id = Number(route.query.id)
|
|
||||||
console.log("id: ", state.id);
|
|
||||||
loadProductDetail()
|
|
||||||
})
|
|
||||||
|
|
||||||
const loadProductDetail = async () => {
|
|
||||||
state.item = await productService.getProductInfo(state.id);
|
|
||||||
console.log("state.item: ", state.item);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => (
|
|
||||||
<div class="ProductDetail">
|
|
||||||
<h1>商品领域模块 - 商品详情页面</h1>
|
|
||||||
<hr />
|
|
||||||
<p>商品id:{state.id}</p>
|
|
||||||
{state.item.name}
|
|
||||||
<ul>
|
|
||||||
{
|
|
||||||
Object.entries(state.item).map(([key, value]) =>
|
|
||||||
<li>{key}: {value}</li>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { defineComponent, onMounted, reactive } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import { productModule } from '@/di/productModule';
|
|
||||||
import { ProductType } from '@domains/product/domain/type/ProductType';
|
|
||||||
|
|
||||||
interface storeType {
|
|
||||||
list: Array<ProductType>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
name: "productModule-ProductList",
|
|
||||||
setup() {
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const productService = productModule.createProductApplicationService();
|
|
||||||
|
|
||||||
const state = reactive<storeType>({
|
|
||||||
list: []
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadProductList()
|
|
||||||
})
|
|
||||||
|
|
||||||
const loadProductList = async () => {
|
|
||||||
state.list = await productService.getProductList();
|
|
||||||
console.log("state.list: ", state.list);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => (
|
|
||||||
<div class="ProductList">
|
|
||||||
<h1>商品领域模块 - 商品列表页面</h1>
|
|
||||||
<hr />
|
|
||||||
<h2>从Deno 后端 加载的数据,点击进详情</h2>
|
|
||||||
<ul>
|
|
||||||
{
|
|
||||||
state.list.map(item =>
|
|
||||||
<li key={item.id} onClick={() => {
|
|
||||||
router.push({
|
|
||||||
name: 'ProductDetail',
|
|
||||||
query: { id: item.id }
|
|
||||||
});
|
|
||||||
}}>
|
|
||||||
{item.id} | {item.name} | ¥{item.price}
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
export default class HttpConfig {
|
export class HttpConfig {
|
||||||
public static DevBaseUrl: string = "http://127.0.0.1:3000/api"
|
public static DevBaseUrl: string = "http://127.0.0.1:3000/api"
|
||||||
public static prodBaseUrl: string = "http://127.0.0.1:9090/api/"
|
public static prodBaseUrl: string = "http://127.0.0.1:3000/api/"
|
||||||
public static apiList: string = ""
|
public static apiList: string = ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export default class ThemeConfig {
|
export class ThemeConfig {
|
||||||
public static primaryColor: string = '#1890ff'; // 主色调
|
public static primaryColor: string = '#1890ff'; // 主色调
|
||||||
public static layoutType: 'side' | 'top' = 'side'; // 侧边栏布局
|
public static layoutType: 'side' | 'top' = 'side'; // 侧边栏布局
|
||||||
}
|
}
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import HttpConfig from './HttpConfig';
|
|
||||||
import ThemeConfig from './ThemeConfig';
|
|
||||||
|
|
||||||
export { HttpConfig, ThemeConfig };
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { HttpConfig } from '@shared/config/index'
|
import { HttpConfig } from '@shared/config/HttpConfig'
|
||||||
|
|
||||||
export default class UrlCheckUtils {
|
export default class UrlCheckUtils {
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { defineConfig } from 'vite'
|
|||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import vueJsx from '@vitejs/plugin-vue-jsx'
|
import vueJsx from '@vitejs/plugin-vue-jsx'
|
||||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||||
|
import federation from '@originjs/vite-plugin-federation';
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -11,7 +12,33 @@ export default defineConfig({
|
|||||||
vue(),
|
vue(),
|
||||||
vueJsx(),
|
vueJsx(),
|
||||||
vueDevTools(),
|
vueDevTools(),
|
||||||
|
federation({
|
||||||
|
name: 'mainApp', // 主应用模块名(联邦模块需通过此名引用)
|
||||||
|
remotes: { // 配置远程联邦模块地址(开发/生产需对应)
|
||||||
|
productModule: 'http://localhost:5001/assets/remoteEntry.js'
|
||||||
|
},
|
||||||
|
exposes: {
|
||||||
|
"./shared": "./src/shared/index.ts",
|
||||||
|
"./productService": "./src/app/di/productModule.ts",
|
||||||
|
},
|
||||||
|
shared: {
|
||||||
|
vue: { requiredVersion: '^3.5.18' },
|
||||||
|
axios: { requiredVersion: '^1.12.2' },
|
||||||
|
'vue-router': { requiredVersion: '^4.0.6' },
|
||||||
|
pinia: { requiredVersion: '^3.0.3' }
|
||||||
|
}
|
||||||
|
})
|
||||||
],
|
],
|
||||||
|
build: {
|
||||||
|
target: 'esnext',
|
||||||
|
minify: false,
|
||||||
|
cssCodeSplit: true,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
minifyInternalExports: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': fileURLToPath(new URL('./src/app/', import.meta.url)),
|
'@': fileURLToPath(new URL('./src/app/', import.meta.url)),
|
||||||
|
|||||||
Reference in New Issue
Block a user