仿真测试台(界面):装配一次试跑 → 起单 → 全链 TRACE 看穿(frontend-design 专项)
测试台从「只能造一封邮件」升级成仿真试跑台,两种试法同屏切换: **装配态**:选生效版流程包 → 按 callInterface.input 现生成参数表单(必填标出)→ 节点指派表(每个活动点/审批点选账号,留空即轮值)→ 起单试跑; 起不来把后端判词原样上屏(如「must have required property 'rfqRef'」)。 **观测态**:WF 工作流图签 · DSK 任务落到了谁的工作台(明说测试台不代答)· **TRC 全链 TRACE(签名元素)** · NOD 节点实例。 TRACE 做成一张走查记录纸:左半 span 树(细线方角枝线),右半同轴甘特(方格纸底纹)。 三处判断值得留: - 轴的右端**只认已知时刻,不认 now** —— 按 now 收口会被不收尾的工作流 span 拉到 十几秒,真干活的两三毫秒压成发丝;而根 span 常常一辈子不收尾,拉到「此刻」 等于替它编了个结束时间 - 未收尾的段用剖面线 + 箭头(图纸的「未完待续」画法) - 包络(流/节点自己不干活)画尺寸线而非实心条 —— 实心会盖过真正干活的那几笔 颜色只花在动作三色(工具石青 / 模型靛 / 人黄铜),结构走灰阶。 顺带修:区头 desc 是纯文本渲染,里面的 markdown `**` 会原样露星号 —— 六处全清;测试台页签名改「联调试跑」(它现在不止入站邮件)。 实测:真起单三次,指派两处都落地(TRACE 的 attributes.assigneeId 与节点实例 受理人一致);浏览器核对装配态与观测态。五闸全过。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -0,0 +1,508 @@
|
||||
/**
|
||||
* 仿真试跑台 —— 装配一次试跑 → 起单 → 看穿全程。
|
||||
*
|
||||
* 这里检测的是**后端的活动与流程**,不是这一屏自己:起单走的是流程包的调用接口
|
||||
* (`POST /api/gov/testbed/run`),与触发器起单同一条路;起出来的是**真任务**,
|
||||
* 照旧落到被指派人的工作台上。测试台不代答 —— 它只管两件事:
|
||||
* ① 把流跑起来(装配态) ② 看穿它怎么跑的(观测态)
|
||||
*/
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import type { ProcessNode, ProcessPackage } from '@contracts';
|
||||
import { TERMINAL_STATE_LABEL, WORKFLOW_STATE_LABEL, testRunRoute } from '@contracts';
|
||||
import { Ico } from '@web-shared/Ico.tsx';
|
||||
import { call } from '@web-shared/typed-fetch.ts';
|
||||
import { useGovPackages, useGovStaff } from '@web-shared/gov/gov-api.ts';
|
||||
import { Acts, Field, Note, PageHead, QueryGate, St } from '@web-shared/gov/primitives.tsx';
|
||||
import type { Nav } from '@web-shared/gov/gov-route.ts';
|
||||
import { CallForm, buildCallData, callFieldsOf, placeholderValues } from './call-form.tsx';
|
||||
import type { CallValues } from './call-form.tsx';
|
||||
import { isLive, useRunTrace, useRunWorkflow } from './run-api.ts';
|
||||
import { TraceSheet } from './trace-sheet.tsx';
|
||||
|
||||
type RunOutput = { workflowId: string; state: string };
|
||||
|
||||
/** 只有活动点与审批点会解析负责人 —— 起点终点没有「派给谁」这回事 */
|
||||
const isAssignable = (n: ProcessNode): boolean => n.kind === 'activity' || n.kind === 'approval';
|
||||
|
||||
const NODE_KIND_LABEL: Readonly<Record<string, string>> = {
|
||||
activity: '活动',
|
||||
approval: '审批',
|
||||
start: '起点',
|
||||
end: '终点',
|
||||
};
|
||||
|
||||
function Sheet({
|
||||
code,
|
||||
title,
|
||||
ico,
|
||||
extra,
|
||||
tight,
|
||||
children,
|
||||
}: {
|
||||
code: string;
|
||||
title: string;
|
||||
ico: string;
|
||||
extra?: ReactNode;
|
||||
tight?: boolean;
|
||||
children: ReactNode;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div className="g-sheet">
|
||||
<div className="g-sheet-h">
|
||||
<Ico name={ico} s={12} />
|
||||
{code} — {title}
|
||||
{extra !== undefined && <span className="x">{extra}</span>}
|
||||
</div>
|
||||
<div className={'g-sheet-b' + (tight === true ? ' tight' : '')}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SimulateRun({ nav }: { nav: Nav }): ReactNode {
|
||||
const pkgQ = useGovPackages();
|
||||
const staffQ = useGovStaff();
|
||||
|
||||
/** 只列有生效版的:同一个包可能存着多版,能起单的只有 `active` 那一版 */
|
||||
const livePkgs = useMemo<ProcessPackage[]>(
|
||||
() => (pkgQ.data?.packages ?? []).filter((p) => p.state === 'active'),
|
||||
[pkgQ.data],
|
||||
);
|
||||
const staff = useMemo(() => (staffQ.data?.staff ?? []).filter((s) => s.state === 'active'), [staffQ.data]);
|
||||
const staffName = (id: string): string => staff.find((s) => s.id === id)?.name ?? id;
|
||||
|
||||
const [pickedId, setPicked] = useState('');
|
||||
const [subject, setSubject] = useState('测试台试跑');
|
||||
const [values, setValues] = useState<CallValues>({});
|
||||
const [assignments, setAssignments] = useState<Readonly<Record<string, string>>>({});
|
||||
const [workflowId, setWorkflowId] = useState<string | undefined>(undefined);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
// 没选就落到第一个有生效版的包 —— 常见情形只有一个包,不该逼人先点一下
|
||||
const pkg = livePkgs.find((p) => p.id === pickedId) ?? livePkgs[0];
|
||||
const fields = useMemo(() => callFieldsOf(pkg?.callInterface.input), [pkg]);
|
||||
const nodes = useMemo(() => (pkg?.nodes ?? []).filter(isAssignable), [pkg]);
|
||||
const built = useMemo(() => buildCallData(fields, values), [fields, values]);
|
||||
const ready = pkg !== undefined && subject.trim() !== '' && built.errors.length === 0;
|
||||
|
||||
const run = useMutation({
|
||||
mutationFn: (body: { packageId: string; subject: string; data: Record<string, unknown>; assignments: Record<string, string> }) =>
|
||||
call<RunOutput>(testRunRoute, { body }),
|
||||
onSuccess: (r) => {
|
||||
setWorkflowId(r.workflowId);
|
||||
setError(undefined);
|
||||
},
|
||||
// 后端的判词照抄:「输入不合调用接口契约」「流程包无生效版本」都是要人改配置的话,不该被我改写
|
||||
onError: (e: unknown) => setError((e as { message?: string }).message ?? '起单失败'),
|
||||
});
|
||||
|
||||
const fire = (): void => {
|
||||
if (pkg === undefined) return;
|
||||
run.mutate({
|
||||
packageId: pkg.id,
|
||||
subject: subject.trim(),
|
||||
data: built.data,
|
||||
assignments: Object.fromEntries(Object.entries(assignments).filter(([, v]) => v !== '')),
|
||||
});
|
||||
};
|
||||
|
||||
if (workflowId !== undefined) {
|
||||
return (
|
||||
<Observe
|
||||
id={workflowId}
|
||||
staffName={staffName}
|
||||
nav={nav}
|
||||
onBack={() => {
|
||||
setWorkflowId(undefined);
|
||||
run.reset();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const pinned = Object.values(assignments).filter((v) => v !== '').length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
ico="flask-conical"
|
||||
tone="brand"
|
||||
kicker="联调测试台 · 装配态"
|
||||
title="仿真试跑"
|
||||
desc="装一次调用、把流真起起来,然后看穿它怎么跑。任务照旧落到被指派人的工作台 —— 测试台不代答。"
|
||||
chips={
|
||||
<>
|
||||
<St tone={pkg === undefined ? 'warn' : 'ok'} ico={pkg === undefined ? 'alert-circle' : 'circle-check'}>
|
||||
{pkg === undefined ? '没有可起单的流程包' : `${pkg.name} v${pkg.version}`}
|
||||
</St>
|
||||
<St tone="mute" ico="user-check">
|
||||
指派 {pinned}/{nodes.length}
|
||||
</St>
|
||||
</>
|
||||
}
|
||||
acts={[
|
||||
{
|
||||
label: run.isPending ? '起单中…' : '起单试跑',
|
||||
ico: 'play',
|
||||
primary: true,
|
||||
disabled: !ready || run.isPending,
|
||||
onClick: fire,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="g-tb">
|
||||
<QueryGate query={pkgQ}>
|
||||
{livePkgs.length === 0 ? (
|
||||
<div className="g-sheet">
|
||||
<div className="g-tb-empty">
|
||||
<Ico name="scan" s={26} />
|
||||
<b>没有可起单的流程包</b>
|
||||
<p>
|
||||
试跑要有一个<b>生效版</b>才起得来。去「流程包」里保存一版 —— 存得进去的版本就是生效的。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 一张装配单读下来就是一次试跑的次序:选包 → 填参 → 派人 → 起单 */}
|
||||
<div className="g-tb-rig">
|
||||
<Sheet code="PKG" title="流程包" ico="workflow" extra={`${livePkgs.length} 个生效`}>
|
||||
<div className="g-tbpkgs">
|
||||
{livePkgs.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
className={'g-tbpkg' + (p.id === pkg?.id ? ' on' : '')}
|
||||
onClick={() => {
|
||||
setPicked(p.id);
|
||||
setValues({});
|
||||
setAssignments({});
|
||||
}}
|
||||
>
|
||||
<b>{p.name}</b>
|
||||
<span className="id">
|
||||
{p.id}@v{p.version}
|
||||
</span>
|
||||
<span className="cnt">
|
||||
<span>节点 {p.nodes.length}</span>
|
||||
<span>边 {p.edges.length}</span>
|
||||
<span>入参 {callFieldsOf(p.callInterface.input).length}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Note>只列有生效版的包。在途的流按起单时那一版跑完,改包不影响它们。</Note>
|
||||
</Sheet>
|
||||
|
||||
<Sheet
|
||||
code="ARG"
|
||||
title="调用参数"
|
||||
ico="sliders-horizontal"
|
||||
extra={pkg === undefined ? '' : `按 ${pkg.id} 的调用接口`}
|
||||
>
|
||||
{fields.length === 0 ? (
|
||||
<Note>这个包的调用接口没声明入参 —— 直接起单即可。</Note>
|
||||
) : (
|
||||
<>
|
||||
<div className="g-tbargs">
|
||||
<CallForm
|
||||
fields={fields}
|
||||
values={values}
|
||||
onChange={(k, v) => setValues((prev) => ({ ...prev, [k]: v }))}
|
||||
/>
|
||||
</div>
|
||||
<Acts
|
||||
align="end"
|
||||
items={[
|
||||
{ label: '填占位值', ico: 'sparkles', onClick: () => setValues(placeholderValues(fields)) },
|
||||
{ label: '清空', ico: 'trash-2', onClick: () => setValues({}) },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
<Sheet code="ASN" title="节点指派" ico="user-check" extra={`${nodes.length} 个可指派 · 已定 ${pinned}`}>
|
||||
{/* 分两栏排:一条流七八个节点,单栏会把这张卡拉得比整块装配单还长 */}
|
||||
<div className="g-asn-2">
|
||||
{[nodes.slice(0, Math.ceil(nodes.length / 2)), nodes.slice(Math.ceil(nodes.length / 2))]
|
||||
.filter((half) => half.length > 0)
|
||||
.map((half, i) => (
|
||||
<div key={i} className="g-asn">
|
||||
<div className="g-asn-h">
|
||||
<span>节点</span>
|
||||
<span>类型</span>
|
||||
<span>名称</span>
|
||||
<span>派给谁</span>
|
||||
</div>
|
||||
{half.map((n) => (
|
||||
<div key={n.id} className={'g-asn-r' + ((assignments[n.id] ?? '') === '' ? '' : ' pinned')}>
|
||||
<span className="nid">{n.id}</span>
|
||||
<span className="kd">{NODE_KIND_LABEL[n.kind] ?? n.kind}</span>
|
||||
<span className="lb">{n.label}</span>
|
||||
<select
|
||||
className="g-select"
|
||||
value={assignments[n.id] ?? ''}
|
||||
onChange={(e) => setAssignments((prev) => ({ ...prev, [n.id]: e.target.value }))}
|
||||
>
|
||||
<option value="">照旧轮值</option>
|
||||
{staff.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name} · {s.position.code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Note>
|
||||
留空即<b>照旧轮值</b>(有员工组按组轮,没有按全员最闲者先)。指派只在那个人<b>接得了这个节点</b>
|
||||
时生效,接不了会退回轮值 —— 实际受理人以 TRACE 里那一行为准。
|
||||
</Note>
|
||||
</Sheet>
|
||||
|
||||
<Sheet code="RUN" title="起单" ico="play" extra={ready ? '装配齐了' : '还差料'}>
|
||||
<div className="g-tbrun">
|
||||
<div className="g-tb-f">
|
||||
<Field k="试跑标题" hint="落在工作流上的题,观测态与运营中心都按它认这一单">
|
||||
<input className="g-input" value={subject} onChange={(e) => setSubject(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
<Acts
|
||||
items={[
|
||||
{
|
||||
label: run.isPending ? '起单中…' : '起单试跑',
|
||||
ico: 'play',
|
||||
primary: true,
|
||||
disabled: !ready || run.isPending,
|
||||
onClick: fire,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{built.errors.length > 0 && (
|
||||
<Note tone="warn">
|
||||
<b>还差 {built.errors.length} 项才起得来:</b>
|
||||
{built.errors.join(' · ')}
|
||||
</Note>
|
||||
)}
|
||||
{error !== undefined && (
|
||||
<Note tone="warn">
|
||||
<b>没起成:</b>
|
||||
{error}
|
||||
</Note>
|
||||
)}
|
||||
</Sheet>
|
||||
</>
|
||||
)}
|
||||
</QueryGate>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 观测态 ─────────────────────────────────────────────────────────── */
|
||||
|
||||
function Observe({
|
||||
id,
|
||||
staffName,
|
||||
nav,
|
||||
onBack,
|
||||
}: {
|
||||
id: string;
|
||||
staffName: (sid: string) => string;
|
||||
nav: Nav;
|
||||
onBack: () => void;
|
||||
}): ReactNode {
|
||||
const wf = useRunWorkflow(id);
|
||||
const state = wf.data?.workflow.state;
|
||||
// 到终态就停轮询:已经不动的东西没必要每两秒问一遍
|
||||
const live = isLive(state);
|
||||
const trQ = useRunTrace(id, live);
|
||||
|
||||
const spans = trQ.data?.spans ?? [];
|
||||
const w = wf.data;
|
||||
const nodeLabel = (nid: string): string => w?.pkg.nodes.find((n) => n.id === nid)?.label ?? nid;
|
||||
/** 节点实例出参里没有受理人,受理人只在 span 的 attributes 上 —— 从 TRACE 反查 */
|
||||
const assigneeOfNode = (nid: string): string | undefined => {
|
||||
const s = spans.find((x) => x.kind === 'node' && x.nodeId === nid);
|
||||
const a = s?.attributes?.['assigneeId'];
|
||||
return typeof a === 'string' ? staffName(a) : undefined;
|
||||
};
|
||||
|
||||
const started = w === undefined ? undefined : new Date(w.workflow.startedAt);
|
||||
const refresh = (): void => {
|
||||
void wf.refetch();
|
||||
void trQ.refetch();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
ico="activity"
|
||||
tone={state === 'abnormal' ? 'danger' : state === 'completed' ? 'ok' : 'brand'}
|
||||
kicker="联调测试台 · 观测态"
|
||||
title={w?.workflow.subject ?? '试跑中'}
|
||||
desc="流已经起了。下面这三样看穿它:全链 TRACE 看它怎么跑,节点实例看它跑到哪,派工带看它把活派给了谁。"
|
||||
chips={
|
||||
<>
|
||||
<St
|
||||
tone={state === 'abnormal' ? 'danger' : state === 'completed' ? 'ok' : 'brand'}
|
||||
ico={state === 'completed' ? 'circle-check' : state === 'abnormal' ? 'alert-triangle' : 'radio'}
|
||||
>
|
||||
{state === undefined ? '取数中' : (WORKFLOW_STATE_LABEL[state] ?? state)}
|
||||
</St>
|
||||
<St tone="mute" ico="hash">
|
||||
{id.slice(0, 8)}
|
||||
</St>
|
||||
{live && (
|
||||
<St tone="mute" ico="refresh-cw">
|
||||
每 2 秒自动刷新
|
||||
</St>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
acts={[
|
||||
{ label: '刷新', ico: 'refresh-cw', onClick: refresh },
|
||||
{ label: '再装一单', ico: 'rotate-ccw', primary: true, onClick: onBack },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="g-tb">
|
||||
<QueryGate query={wf}>
|
||||
{w !== undefined && (
|
||||
<>
|
||||
{/* 图签栏:工程图右下角那张表 —— 一条流的身份,一格一件事 */}
|
||||
<div className="g-sheet">
|
||||
<div className="g-sheet-h">
|
||||
<Ico name="file-text" s={12} />
|
||||
WF — 工作流
|
||||
<span className="x">{w.workflow.source.kind} · {w.workflow.source.ref}</span>
|
||||
</div>
|
||||
<div className="g-tblock">
|
||||
<div>
|
||||
<em>工作流号</em>
|
||||
<b>{id.slice(0, 8)}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>状态</em>
|
||||
<b className={state === 'running' || state === 'waiting' ? 'run' : 'brass'}>
|
||||
{WORKFLOW_STATE_LABEL[w.workflow.state] ?? w.workflow.state}
|
||||
</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>流程包</em>
|
||||
<b>{w.workflow.packageId}@v{w.workflow.packageVersion}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>起单时刻</em>
|
||||
<b>{started?.toLocaleTimeString('zh-CN', { hour12: false }) ?? '—'}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>起单人</em>
|
||||
<b>{w.workflow.source.by === undefined ? '—' : staffName(w.workflow.source.by)}</b>
|
||||
</div>
|
||||
<div>
|
||||
<em>节点 / 边</em>
|
||||
<b>
|
||||
{w.pkg.nodes.length} / {w.pkg.edges.length}
|
||||
</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 派工带:测试台不代答,这句必须说满 */}
|
||||
<div className="g-tbdesk">
|
||||
<div className="g-tbdesk-h">
|
||||
<Ico name="briefcase" s={12} />
|
||||
DSK — 任务落到了谁的工作台
|
||||
<button className="g-btn xs" onClick={() => nav.jump('tasks', 'list')}>
|
||||
<Ico name="activity" s={11} />
|
||||
去运营中心 · 任务
|
||||
</button>
|
||||
</div>
|
||||
{w.tasks.length === 0 ? (
|
||||
<p>还没有任务落到任何工位 —— 流刚起,或者卡在审批点上(审批单不是任务,见下面的节点实例)。</p>
|
||||
) : (
|
||||
w.tasks.map((t) => (
|
||||
<div key={t.id} className="g-tbdesk-r">
|
||||
<span className="nid">{t.nodeId}</span>
|
||||
{t.title}
|
||||
<span>→</span>
|
||||
<b>{t.ownerName} 的工作台</b>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<p>
|
||||
测试台<b>不代答</b>:这些是真任务,要那个人在自己的工作台上答,这条流才会往下走。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TraceSheet
|
||||
spans={spans}
|
||||
staffName={staffName}
|
||||
extra={
|
||||
<button className="g-btn xs" onClick={refresh} disabled={trQ.isFetching}>
|
||||
<Ico name="refresh-cw" s={11} />
|
||||
{trQ.isFetching ? '取数中' : '刷新'}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="g-sheet">
|
||||
<div className="g-sheet-h">
|
||||
<Ico name="list-checks" s={12} />
|
||||
NOD — 节点实例
|
||||
<span className="x">{w.instances.length} 个已进入</span>
|
||||
</div>
|
||||
{w.instances.length === 0 ? (
|
||||
<div className="g-tb-empty">
|
||||
<Ico name="scan" s={26} />
|
||||
<b>还没有节点进入</b>
|
||||
<p>起点已过,第一个节点正在派工。</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="g-nins">
|
||||
<div className="g-nins-h">
|
||||
<span>节点</span>
|
||||
<span>名称</span>
|
||||
<span>状态</span>
|
||||
<span>终态</span>
|
||||
<span>受理人</span>
|
||||
</div>
|
||||
{w.instances.map((it) => {
|
||||
const bad = it.terminal !== undefined && it.terminal !== 'released';
|
||||
const cls = bad ? 'bad' : it.running ? 'run' : it.waiting ? 'wait' : it.terminal === undefined ? '' : 'done';
|
||||
const who = assigneeOfNode(it.nodeId);
|
||||
return (
|
||||
<div key={`${it.nodeId}#${it.instance}`} className="g-nins-r">
|
||||
<span className="nid">{it.nodeId}</span>
|
||||
<span className="lb">{nodeLabel(it.nodeId)}</span>
|
||||
<span className={`st ${cls}`}>
|
||||
<i />
|
||||
{bad ? '异常' : it.running ? '在跑' : it.waiting ? '等人' : it.terminal === undefined ? '已进入' : '已放行'}
|
||||
</span>
|
||||
<span className="lb">
|
||||
{it.terminal === undefined ? '—' : (TERMINAL_STATE_LABEL[it.terminal] ?? it.terminal)}
|
||||
</span>
|
||||
<span className="who">
|
||||
{who === undefined ? '—' : <b>{who}</b>}
|
||||
{it.instance > 1 && <span className="ins"> 第 {it.instance} 次</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</QueryGate>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user