feat(frontend): 管理端页面 — 任务全景树/创建产品/标签打印/认证守卫
App.tsx: - AntApp + AuthProvider + ToastProvider 三层包裹 - /admin/login 独立登录页 - /admin/* 路由认证守卫 AdminLayout: - 未登录→跳转登录; 顶部栏显示用户名+登出 - 侧边栏: 全局概览/产品管理/任务全景 CreateProductDialog (Ant Design 重写): - MOM物料手风琴选择器 (Collapse+Table) - 分组懒加载 + useRef缓存 + 防抖搜索 - HEX ID只读展示 + 序列号/订单号选填 AdminProductsPage: - 打印预览弹窗 (Base64标签预览 + 份数选择) - 打印机设置入口 → AdminPrintConfigPage 扫码组件对齐: - ProductCard: material_name/spec_model/current_location - TaskListCard: 递归 task_tree + 状态配色 + 返工/裂变标签 - QueryResult: task_tree 优先 - ScanPage: 宏观状态栏
This commit is contained in:
@ -1,7 +1,12 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { X, Loader2, QrCode } from "lucide-react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Modal, Collapse, Table, Input, Button, Space, Tag, App, Spin, Empty } from "antd";
|
||||
import { SearchOutlined, PlusOutlined, MinusOutlined } from "@ant-design/icons";
|
||||
import api from "../../services/api";
|
||||
import { listOrders, type OrderOption } from "../../services/orderApi";
|
||||
import { fetchMaterialGroups, fetchMaterialItems, type MaterialGroup, type MaterialItem } from "../../services/materialApi";
|
||||
|
||||
// ============================================================
|
||||
// 类型
|
||||
// ============================================================
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@ -9,181 +14,444 @@ interface Props {
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
/** 生成 16 位随机 hex 序列号 */
|
||||
function genSerial(): string {
|
||||
const chars = "0123456789ABCDEF";
|
||||
let s = "";
|
||||
for (let i = 0; i < 16; i++) {
|
||||
s += chars[Math.floor(Math.random() * 16)];
|
||||
}
|
||||
return s;
|
||||
interface SelectedMaterial {
|
||||
material_id: string;
|
||||
material_name: string;
|
||||
spec_model: string;
|
||||
category: string;
|
||||
material_type: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 主组件
|
||||
// ============================================================
|
||||
|
||||
export default function CreateProductDialog({ open, onClose, onCreated }: Props) {
|
||||
const [orders, setOrders] = useState<OrderOption[]>([]);
|
||||
const [serialNumber, setSerialNumber] = useState(genSerial());
|
||||
const [orderId, setOrderId] = useState("");
|
||||
const [materialId, setMaterialId] = useState("");
|
||||
const { message } = App.useApp();
|
||||
|
||||
// ---- 搜索 & 分组摘要 ----
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [summary, setSummary] = useState<MaterialGroup[]>([]);
|
||||
const [summaryLoading, setSummaryLoading] = useState(false);
|
||||
|
||||
// ---- 手风琴展开 keys ----
|
||||
const [activeKeys, setActiveKeys] = useState<string[]>([]);
|
||||
|
||||
// ---- 缓存 (对标老系统 groupCache / groupLoadingMap) ----
|
||||
const groupCache = useRef<Map<string, MaterialItem[]>>(new Map());
|
||||
const groupLoadingMap = useRef<Map<string, boolean>>(new Map());
|
||||
|
||||
// ---- 选中物料 ----
|
||||
const [selected, setSelected] = useState<SelectedMaterial | null>(null);
|
||||
|
||||
// ---- 表单 ----
|
||||
const [externalSerial, setExternalSerial] = useState("");
|
||||
const [orderNo, setOrderNo] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [createdSn, setCreatedSn] = useState<string | null>(null);
|
||||
|
||||
// ---- 初始化 ----
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSerialNumber(genSerial());
|
||||
setError(null);
|
||||
setKeyword("");
|
||||
setActiveKeys([]);
|
||||
setSelected(null);
|
||||
setExternalSerial("");
|
||||
setOrderNo("");
|
||||
setCreatedSn(null);
|
||||
listOrders()
|
||||
.then(setOrders)
|
||||
.catch(() => setError("加载订单列表失败"));
|
||||
groupCache.current.clear();
|
||||
groupLoadingMap.current.clear();
|
||||
loadSummary();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!orderId) { setError("请选择订单"); return; }
|
||||
if (serialNumber.length !== 16) { setError("序列号必须为16位"); return; }
|
||||
// ============================================================
|
||||
// 数据加载
|
||||
// ============================================================
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
/** 搜索分组摘要 */
|
||||
const loadSummary = useCallback(async (kw?: string) => {
|
||||
setSummaryLoading(true);
|
||||
try {
|
||||
await api.post("/products/", {
|
||||
serial_number: serialNumber,
|
||||
order_id: orderId,
|
||||
material_id: materialId || null,
|
||||
const list = await fetchMaterialGroups(kw?.trim() || undefined);
|
||||
setSummary(list);
|
||||
} catch {
|
||||
message.error("加载物料分组失败");
|
||||
} finally {
|
||||
setSummaryLoading(false);
|
||||
}
|
||||
}, [message]);
|
||||
|
||||
function handleSearch() {
|
||||
setActiveKeys([]);
|
||||
groupCache.current.clear();
|
||||
groupLoadingMap.current.clear();
|
||||
loadSummary(keyword.trim() || undefined);
|
||||
}
|
||||
|
||||
// 防抖搜索:输入即搜,300ms 无键入后自动触发
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
handleSearch();
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [keyword]);
|
||||
|
||||
/** 懒加载分组内物料 */
|
||||
async function loadGroupItems(category: string) {
|
||||
// 缓存命中 → 跳过
|
||||
if (groupCache.current.has(category)) return;
|
||||
// 正在加载 → 跳过
|
||||
if (groupLoadingMap.current.get(category)) return;
|
||||
|
||||
groupLoadingMap.current.set(category, true);
|
||||
// 触发重渲染让 Table 显示 loading
|
||||
forceRefresh();
|
||||
|
||||
try {
|
||||
const items = await fetchMaterialItems(category, keyword.trim() || undefined);
|
||||
groupCache.current.set(category, items);
|
||||
} catch {
|
||||
message.error(`加载 "${category}" 分组失败`);
|
||||
} finally {
|
||||
groupLoadingMap.current.set(category, false);
|
||||
forceRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
/** 强制刷新 — 因为 useRef 不会触发重渲染,借用 state 刷新 */
|
||||
const [, setTick] = useState(0);
|
||||
function forceRefresh() {
|
||||
setTick((t) => t + 1);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 手风琴事件
|
||||
// ============================================================
|
||||
|
||||
function handleCollapseChange(keys: string | string[]) {
|
||||
const newKeys = Array.isArray(keys) ? keys : [keys];
|
||||
setActiveKeys(newKeys);
|
||||
|
||||
// 新展开的 panel → 懒加载
|
||||
const newlyOpened = newKeys.filter((k) => !activeKeys.includes(k));
|
||||
newlyOpened.forEach((cat) => loadGroupItems(cat));
|
||||
}
|
||||
|
||||
function expandAll() {
|
||||
const all = summary.map((g) => g.category);
|
||||
setActiveKeys(all);
|
||||
all.forEach((cat) => loadGroupItems(cat));
|
||||
}
|
||||
|
||||
function collapseAll() {
|
||||
setActiveKeys([]);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 选择物料
|
||||
// ============================================================
|
||||
|
||||
function handleSelect(item: MaterialItem) {
|
||||
setSelected({
|
||||
material_id: String(item.id),
|
||||
material_name: item.name,
|
||||
spec_model: item.spec,
|
||||
category: item.category,
|
||||
material_type: item.type,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 提交创建
|
||||
// ============================================================
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!selected) {
|
||||
message.warning("请选择一个物料");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const { data } = await api.post("/products/", {
|
||||
material_id: selected.material_id,
|
||||
material_name: selected.material_name,
|
||||
spec_model: selected.spec_model,
|
||||
category: selected.category,
|
||||
material_type: selected.material_type,
|
||||
external_serial: externalSerial.trim() || null,
|
||||
order_no: orderNo.trim() || null,
|
||||
});
|
||||
setCreatedSn(serialNumber);
|
||||
setCreatedSn(data.serial_number);
|
||||
onCreated();
|
||||
} catch (err: unknown) {
|
||||
const detail =
|
||||
err && typeof err === "object" && "response" in err
|
||||
? (err as { response?: { data?: { detail?: unknown } } }).response?.data?.detail
|
||||
: null;
|
||||
setError(
|
||||
typeof detail === "string"
|
||||
? detail
|
||||
: JSON.stringify(detail) || "创建失败"
|
||||
);
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.detail ?? "创建失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
// ============================================================
|
||||
// 表格列定义
|
||||
// ============================================================
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: "名称",
|
||||
dataIndex: "name",
|
||||
key: "name",
|
||||
ellipsis: true,
|
||||
render: (v: string) => <span className="font-medium text-gray-800">{v}</span>,
|
||||
},
|
||||
{
|
||||
title: "规格",
|
||||
dataIndex: "spec",
|
||||
key: "spec",
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: "类型",
|
||||
dataIndex: "type",
|
||||
key: "type",
|
||||
width: 80,
|
||||
render: (v: string) => <Tag>{v}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "单位",
|
||||
dataIndex: "unit",
|
||||
key: "unit",
|
||||
width: 60,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "action",
|
||||
width: 80,
|
||||
render: (_: unknown, record: MaterialItem) => (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSelect(record);
|
||||
}}
|
||||
>
|
||||
选择
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================================
|
||||
// Collapse items 生成
|
||||
// ============================================================
|
||||
|
||||
const collapseItems = summary.map((group) => {
|
||||
const items = groupCache.current.get(group.category);
|
||||
const isLoading = groupLoadingMap.current.get(group.category) === true;
|
||||
|
||||
return {
|
||||
key: group.category,
|
||||
label: (
|
||||
<div className="flex items-center justify-between pr-2">
|
||||
<span className="text-sm font-medium text-gray-700">{group.category}</span>
|
||||
<Tag className="ml-2">{group.count}</Tag>
|
||||
</div>
|
||||
),
|
||||
children: isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spin />
|
||||
</div>
|
||||
) : items && items.length > 0 ? (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
scroll={{ y: 240 }}
|
||||
onRow={(record) => ({
|
||||
className:
|
||||
selected?.material_id === String(record.id) ? "bg-blue-50" : "",
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="该分类下暂无物料"
|
||||
className="py-6"
|
||||
/>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 渲染
|
||||
// ============================================================
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="w-full max-w-md rounded-xl bg-white shadow-xl">
|
||||
{/* 标题 */}
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-6 py-4">
|
||||
<h3 className="text-lg font-semibold text-gray-800">创建产品</h3>
|
||||
<button onClick={onClose} className="rounded-md p-1 text-gray-400 hover:bg-gray-100">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
<Modal
|
||||
title="创建产品"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
width={720}
|
||||
footer={null}
|
||||
destroyOnHidden
|
||||
>
|
||||
{createdSn ? (
|
||||
/* ---- 成功页 ---- */
|
||||
<div className="flex flex-col items-center py-6">
|
||||
<img
|
||||
src={`/api/v1/products/qrcode/${createdSn}`}
|
||||
alt={`QR-${createdSn}`}
|
||||
className="h-48 w-48 rounded-lg border"
|
||||
/>
|
||||
<p className="mt-3 font-mono text-lg font-bold tracking-widest text-gray-800">
|
||||
{createdSn}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-green-600">产品创建成功!</p>
|
||||
<Space className="mt-5">
|
||||
<Button onClick={() => window.open(`/api/v1/products/qrcode/${createdSn}`, "_blank")}>
|
||||
打开二维码
|
||||
</Button>
|
||||
<Button type="primary" onClick={() => setCreatedSn(null)}>
|
||||
继续创建
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 创建成功 — 展示二维码 */}
|
||||
{createdSn ? (
|
||||
<div className="flex flex-col items-center px-6 py-8">
|
||||
<img
|
||||
src={`/api/v1/products/qrcode/${createdSn}`}
|
||||
alt={`QR-${createdSn}`}
|
||||
className="h-52 w-52 rounded-lg border"
|
||||
/>
|
||||
<p className="mt-4 font-mono text-lg font-bold tracking-wider text-gray-800">
|
||||
{createdSn}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-green-600">产品创建成功!</p>
|
||||
<div className="mt-6 flex w-full gap-3">
|
||||
<button
|
||||
onClick={() => window.open(`/api/v1/products/qrcode/${createdSn}`, "_blank")}
|
||||
className="flex-1 rounded-lg border border-gray-200 py-2.5 text-sm font-medium text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
打开二维码
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setCreatedSn(null);
|
||||
setSerialNumber(genSerial());
|
||||
}}
|
||||
className="flex-1 rounded-lg bg-blue-600 py-2.5 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
继续创建
|
||||
</button>
|
||||
) : (
|
||||
/* ---- 表单 ---- */
|
||||
<div className="space-y-4">
|
||||
{/* ================================================================ */}
|
||||
{/* 物料选择区域 */}
|
||||
{/* ================================================================ */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
MOM 物料 <span className="text-red-500">*</span>
|
||||
</span>
|
||||
{!selected && (
|
||||
<Space size="small">
|
||||
<Button size="small" icon={<PlusOutlined />} onClick={expandAll}>
|
||||
全部展开
|
||||
</Button>
|
||||
<Button size="small" icon={<MinusOutlined />} onClick={collapseAll}>
|
||||
全部折叠
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={onClose} className="mt-3 w-full rounded-lg py-2 text-sm text-gray-400 hover:text-gray-600">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* 表单 */
|
||||
<form onSubmit={handleSubmit} className="space-y-4 px-6 py-5">
|
||||
{/* 序列号 */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">产品序列号 (16位)</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={serialNumber}
|
||||
onChange={(e) => setSerialNumber(e.target.value.toUpperCase())}
|
||||
maxLength={16}
|
||||
className="flex-1 rounded-lg border border-gray-200 px-3 py-2 font-mono text-sm focus:border-blue-500 focus:outline-none"
|
||||
placeholder="16位HEX序列号"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSerialNumber(genSerial())}
|
||||
className="rounded-lg border border-gray-200 px-3 py-2 text-xs text-gray-500 hover:bg-gray-50"
|
||||
>
|
||||
随机
|
||||
</button>
|
||||
|
||||
{/* 已选物料 → 折叠手风琴,展示紧凑标签 */}
|
||||
{selected ? (
|
||||
<div className="flex items-center justify-between rounded-lg border border-blue-200 bg-blue-50 px-4 py-3">
|
||||
<div>
|
||||
<span className="text-base font-semibold text-blue-800">
|
||||
{selected.material_name}
|
||||
</span>
|
||||
<div className="mt-0.5 flex gap-3 text-xs text-blue-500">
|
||||
<span>规格: {selected.spec_model}</span>
|
||||
<span>分类: {selected.category}</span>
|
||||
<span>类型: {selected.material_type}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button danger size="small" onClick={() => setSelected(null)}>
|
||||
清除重选
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 搜索栏 */}
|
||||
<div className="mb-2 flex gap-2">
|
||||
<Input
|
||||
placeholder="搜索名称/规格…"
|
||||
prefix={<SearchOutlined />}
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onPressEnter={handleSearch}
|
||||
allowClear
|
||||
/>
|
||||
<Button onClick={handleSearch}>搜索</Button>
|
||||
</div>
|
||||
|
||||
{/* 订单 */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">所属订单</label>
|
||||
<select
|
||||
value={orderId}
|
||||
onChange={(e) => setOrderId(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
||||
required
|
||||
>
|
||||
<option value="">请选择订单</option>
|
||||
{orders.map((o) => (
|
||||
<option key={o.id} value={o.id}>{o.order_no}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 物料 ID */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
物料 ID <span className="text-gray-400">(可选)</span>
|
||||
</label>
|
||||
<input
|
||||
value={materialId}
|
||||
onChange={(e) => setMaterialId(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
||||
placeholder="关联老系统物料"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</div>
|
||||
{/* 手风琴 */}
|
||||
<div className="max-h-[360px] overflow-y-auto rounded-lg border border-gray-200">
|
||||
{summaryLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Spin />
|
||||
</div>
|
||||
) : summary.length === 0 ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="暂无成品/半成品数据"
|
||||
className="py-10"
|
||||
/>
|
||||
) : (
|
||||
<Collapse
|
||||
activeKey={activeKeys}
|
||||
onChange={handleCollapseChange}
|
||||
size="small"
|
||||
ghost
|
||||
items={collapseItems}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 py-2.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-60"
|
||||
>
|
||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <QrCode className="h-4 w-4" />}
|
||||
创建并生成二维码
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* ================================================================ */}
|
||||
{/* 系统唯一 ID(只读) */}
|
||||
{/* ================================================================ */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
系统唯一 ID <span className="text-xs text-gray-400">(自动生成)</span>
|
||||
</label>
|
||||
<Input
|
||||
value="提交后自动生成 16 位 HEX"
|
||||
disabled
|
||||
className="font-mono text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 产品序列号(选填) */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
产品序列号 <span className="text-xs text-gray-400">(选填)</span>
|
||||
</label>
|
||||
<Input
|
||||
value={externalSerial}
|
||||
onChange={(e) => setExternalSerial(e.target.value)}
|
||||
maxLength={64}
|
||||
placeholder="用户自定义序列号"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 所属订单(选填) */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
所属订单 <span className="text-xs text-gray-400">(选填)</span>
|
||||
</label>
|
||||
<Input
|
||||
value={orderNo}
|
||||
onChange={(e) => setOrderNo(e.target.value)}
|
||||
maxLength={64}
|
||||
placeholder="自由键入订单号"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 提交 */}
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
size="large"
|
||||
loading={submitting}
|
||||
disabled={!selected}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
创建并生成二维码
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user