fix: harden channel retry attribution and operations UI
This commit is contained in:
@@ -11,10 +11,30 @@ type AdminCustomersPageProps = {
|
||||
|
||||
type CustomerRow = TenantManagementRow;
|
||||
|
||||
function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCancel: () => void; onConfirm: () => void }) {
|
||||
function ConfirmModal({
|
||||
error,
|
||||
isSubmitting,
|
||||
message,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
type,
|
||||
}: {
|
||||
error: string;
|
||||
isSubmitting: boolean;
|
||||
message: string;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
type: 'toggle' | 'delete';
|
||||
}) {
|
||||
return (
|
||||
<Modal footer={<><Button onClick={onCancel} variant="ghost">取消</Button><Button onClick={onConfirm}>确认</Button></>} onClose={onCancel} open title="操作确认">
|
||||
<Modal
|
||||
footer={<><Button disabled={isSubmitting} onClick={onCancel} variant="ghost">取消</Button><Button disabled={isSubmitting} onClick={onConfirm} variant={type === 'delete' ? 'danger' : 'primary'}>{isSubmitting ? '处理中...' : '确认'}</Button></>}
|
||||
onClose={() => { if (!isSubmitting) onCancel(); }}
|
||||
open
|
||||
title={type === 'delete' ? '删除企业' : '变更企业状态'}
|
||||
>
|
||||
<p className="admin-confirm-text">{message}</p>
|
||||
{error ? <p className="form-error" role="alert">{error}</p> : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +46,8 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
|
||||
const [queryStatus, setQueryStatus] = useState('all');
|
||||
const [filters, setFilters] = useState({ name: '', status: 'all' });
|
||||
const [confirmAction, setConfirmAction] = useState<{ type: 'toggle' | 'delete'; record: CustomerRow } | null>(null);
|
||||
const [confirmError, setConfirmError] = useState('');
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [rechargeTarget, setRechargeTarget] = useState<CustomerRow | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
@@ -82,10 +104,10 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
|
||||
<div className="table-actions">
|
||||
<Button icon={<DollarSign size={15} />} onClick={() => openRechargeModal(record)} size="sm" variant="ghost">充值</Button>
|
||||
<Button onClick={() => navigate(`${basePath}/${record.id}/edit`)} size="sm" variant="ghost">编辑</Button>
|
||||
<Button onClick={() => setConfirmAction({ type: 'toggle', record })} size="sm" variant={record.status === 'active' ? 'warning' : 'success'}>
|
||||
<Button onClick={() => openConfirmAction({ type: 'toggle', record })} size="sm" variant={record.status === 'active' ? 'warning' : 'success'}>
|
||||
{record.status === 'active' ? '禁用' : '启用'}
|
||||
</Button>
|
||||
<Button icon={<Trash2 size={15} />} onClick={() => setConfirmAction({ type: 'delete', record })} size="sm" variant="danger">删除</Button>
|
||||
<Button icon={<Trash2 size={15} />} onClick={() => openConfirmAction({ type: 'delete', record })} size="sm" variant="danger">删除</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -95,15 +117,35 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
|
||||
setRechargeTarget(record);
|
||||
}
|
||||
|
||||
function submitConfirmAction() {
|
||||
function openConfirmAction(action: { type: 'toggle' | 'delete'; record: CustomerRow }) {
|
||||
setConfirmError('');
|
||||
setConfirmAction(action);
|
||||
}
|
||||
|
||||
async function submitConfirmAction() {
|
||||
if (!confirmAction) return;
|
||||
const action = confirmAction.type === 'delete'
|
||||
? adminApi.deleteTenant(confirmAction.record.id)
|
||||
: adminApi.changeTenantStatus(confirmAction.record.id, confirmAction.record.status === 'active' ? 'disabled' : 'active');
|
||||
action.then(() => {
|
||||
setConfirmAction(null);
|
||||
loadData();
|
||||
}).catch((failure: Error) => setError(failure.message || '企业状态更新失败'));
|
||||
setConfirming(true);
|
||||
setConfirmError('');
|
||||
try {
|
||||
if (confirmAction.type === 'delete') {
|
||||
await adminApi.deleteTenant(confirmAction.record.id);
|
||||
} else {
|
||||
await adminApi.changeTenantStatus(confirmAction.record.id, confirmAction.record.status === 'active' ? 'disabled' : 'active');
|
||||
}
|
||||
} catch (failure) {
|
||||
const detail = failure instanceof Error ? failure.message : '企业操作失败';
|
||||
setConfirmError(`${confirmAction.type === 'delete' ? '删除' : '状态变更'}失败:${detail}`);
|
||||
setConfirming(false);
|
||||
return;
|
||||
}
|
||||
setConfirmAction(null);
|
||||
try {
|
||||
await loadData();
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '企业列表刷新失败');
|
||||
} finally {
|
||||
setConfirming(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -140,9 +182,12 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
|
||||
|
||||
{confirmAction ? (
|
||||
<ConfirmModal
|
||||
error={confirmError}
|
||||
isSubmitting={confirming}
|
||||
message={confirmAction.type === 'delete' ? `确认删除企业“${confirmAction.record.name}”吗?` : `确认${confirmAction.record.status === 'active' ? '禁用' : '启用'}企业“${confirmAction.record.name}”吗?`}
|
||||
onCancel={() => setConfirmAction(null)}
|
||||
onConfirm={submitConfirmAction}
|
||||
onCancel={() => { setConfirmAction(null); setConfirmError(''); }}
|
||||
onConfirm={() => void submitConfirmAction()}
|
||||
type={confirmAction.type}
|
||||
/>
|
||||
) : null}
|
||||
<ManualRechargeDialog
|
||||
|
||||
@@ -35,6 +35,20 @@ const deliveryTypeLabel: Record<string, string> = {
|
||||
uplink: '上行短信',
|
||||
};
|
||||
|
||||
const attemptStatusLabel: Record<string, string> = {
|
||||
awaiting_ack: '等待客户端确认',
|
||||
acknowledged: '客户端已确认',
|
||||
rejected: '客户端拒绝',
|
||||
failed: '投递失败',
|
||||
};
|
||||
|
||||
function attemptStatusTone(status: string) {
|
||||
if (status === 'acknowledged') return 'success' as const;
|
||||
if (status === 'rejected' || status === 'failed') return 'danger' as const;
|
||||
if (status === 'awaiting_ack') return 'info' as const;
|
||||
return 'neutral' as const;
|
||||
}
|
||||
|
||||
function DeliveryDetailModal({ record, onClose }: { record: DownstreamDeliveryRecord; onClose: () => void }) {
|
||||
const payloadText = useMemo(() => JSON.stringify(record.payload ?? {}, null, 2), [record.payload]);
|
||||
|
||||
@@ -68,29 +82,33 @@ function DeliveryDetailModal({ record, onClose }: { record: DownstreamDeliveryRe
|
||||
</div>
|
||||
<section className="report-history">
|
||||
<h3>逐次投递记录</h3>
|
||||
<div className="downstream-attempt-table" role="table" aria-label="逐次投递记录">
|
||||
<div className="downstream-attempt-table__header" role="row">
|
||||
<span>次数 / 状态</span><span>发送 / ACK 时间</span><span>连接 / Sequence / Msg_Id</span><span>结果</span>
|
||||
</div>
|
||||
<div className="downstream-attempt-timeline" aria-label="逐次投递记录">
|
||||
{(record.attempts ?? []).map((attempt) => (
|
||||
<div key={attempt.id} role="row">
|
||||
<strong>第 {attempt.attemptNo} 次<br />{attempt.status}</strong>
|
||||
<span>
|
||||
{attempt.sentAt ? formatDateTime(attempt.sentAt) : '-'}
|
||||
<br />
|
||||
ACK:{attempt.acknowledgedAt ? formatDateTime(attempt.acknowledgedAt) : '-'}
|
||||
</span>
|
||||
<span>
|
||||
{attempt.connectionId ?? '-'}
|
||||
<br />
|
||||
Seq:{attempt.sequenceId ?? '-'} / Msg:{attempt.messageId ?? '-'}
|
||||
</span>
|
||||
<span>
|
||||
ACK Result:{attempt.ackResult ?? '-'}
|
||||
<br />
|
||||
{attempt.errorMessage ?? attempt.failureType ?? '-'}
|
||||
</span>
|
||||
</div>
|
||||
<article className="downstream-attempt-card" key={attempt.id}>
|
||||
<div className={`downstream-attempt-marker downstream-attempt-marker--${attemptStatusTone(attempt.status)}`}>
|
||||
{attempt.attemptNo}
|
||||
</div>
|
||||
<div className="downstream-attempt-card__body">
|
||||
<header>
|
||||
<strong>第 {attempt.attemptNo} 次投递</strong>
|
||||
<Tag tone={attemptStatusTone(attempt.status)}>{attemptStatusLabel[attempt.status] ?? attempt.status}</Tag>
|
||||
</header>
|
||||
<dl className="downstream-attempt-card__times">
|
||||
<div><dt>发送时间</dt><dd>{attempt.sentAt ? formatDateTime(attempt.sentAt) : '-'}</dd></div>
|
||||
<div><dt>ACK 时间</dt><dd>{attempt.acknowledgedAt ? formatDateTime(attempt.acknowledgedAt) : '-'}</dd></div>
|
||||
<div><dt>ACK 截止</dt><dd>{attempt.ackDeadlineAt ? formatDateTime(attempt.ackDeadlineAt) : '-'}</dd></div>
|
||||
</dl>
|
||||
<dl className="downstream-attempt-card__identifiers">
|
||||
<div><dt>连接 ID</dt><dd>{attempt.connectionId ?? '-'}</dd></div>
|
||||
<div><dt>Sequence_Id</dt><dd>{attempt.sequenceId ?? '-'}</dd></div>
|
||||
<div><dt>Msg_Id</dt><dd>{attempt.messageId ?? '-'}</dd></div>
|
||||
</dl>
|
||||
<div className={`downstream-attempt-result${attempt.errorMessage || attempt.failureType ? ' downstream-attempt-result--error' : ''}`}>
|
||||
<span>ACK Result:{attempt.ackResult ?? '-'}</span>
|
||||
<strong>{attempt.errorMessage ?? attempt.failureType ?? '本次投递未记录异常'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{(record.attempts ?? []).length === 0 ? <p>暂无逐次投递记录</p> : null}
|
||||
</div>
|
||||
|
||||
@@ -49,6 +49,18 @@ type SignatureFormState = {
|
||||
type CarrierReportSummary = { status: string; approved: number; total: number };
|
||||
type SignatureCardTone = 'green' | 'blue' | 'amber' | 'red' | 'gray';
|
||||
|
||||
const carrierLabels: Record<string, string> = {
|
||||
mobile: '移动',
|
||||
unicom: '联通',
|
||||
telecom: '电信',
|
||||
all: '全网',
|
||||
};
|
||||
|
||||
function carrierLabel(carrier?: string | null) {
|
||||
if (!carrier) return '未标注运营商';
|
||||
return carrierLabels[carrier] ?? carrier;
|
||||
}
|
||||
|
||||
function CarrierReportTag({ summary }: { summary?: CarrierReportSummary }) {
|
||||
if (!summary || summary.status === 'not_applicable' || summary.total === 0) return <Tag tone="neutral">不适用</Tag>;
|
||||
let label = '未报备';
|
||||
@@ -477,7 +489,7 @@ function SignatureReportModal({ item, onClose }: { item: ClientSmsSignature; onC
|
||||
<button className="admin-report-carrier--unicom active" type="button"><strong>联通</strong><span><CarrierReportTag summary={item.carrierReportSummary?.unicom} /></span></button>
|
||||
<button className="admin-report-carrier--telecom active" type="button"><strong>电信</strong><span><CarrierReportTag summary={item.carrierReportSummary?.telecom} /></span></button>
|
||||
</div>
|
||||
<div className="page-stack">{(item.reportTargets ?? []).map((target) => <div className="surface" key={target.channelId} style={{ display: 'flex', justifyContent: 'space-between', padding: 12 }}><span>{target.channel.name}({target.channel.carrier})</span><CarrierReportTag summary={{ status: target.status, approved: target.status === 'approved' ? 1 : 0, total: 1 }} /></div>)}</div>
|
||||
<div className="page-stack">{(item.reportTargets ?? []).map((target) => <div className="surface" key={target.channelId} style={{ display: 'flex', justifyContent: 'space-between', padding: 12 }}><span>{target.channel.name}({carrierLabel(target.channel.carrier)})</span><CarrierReportTag summary={{ status: target.status, approved: target.status === 'approved' ? 1 : 0, total: 1 }} /></div>)}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
@@ -505,7 +517,7 @@ function ChannelReportStatusModal({ item, onClose, onSaved }: { item: ClientSmsS
|
||||
return <Modal footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={!targets.length || saving} onClick={() => void save()}>{saving ? '保存中...' : '保存状态'}</Button></>} onClose={onClose} open size="xl" title="按通道修改签名报备状态">
|
||||
<div className="page-stack"><div className="signature-alert"><Info size={18} /><span>企业签名只展示汇总结果;这里修改的是每个具体通道的报备任务,保存后会同步通道详情、报备任务和企业签名三网状态。</span></div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
{targets.length ? targets.map((target) => <div className="surface admin-report-target-row" key={target.channelId}><div><strong>{target.channel.name}</strong><div className="muted">{target.channel.carrier ?? '未标注运营商'} · {target.channel.code}</div></div><Select onChange={(event) => setStatuses((current) => ({ ...current, [target.channelId]: event.target.value }))} options={reportStatusOptions} value={statuses[target.channelId] ?? target.status} /></div>) : <div className="empty-state">该企业应用当前没有配置目标通道。</div>}
|
||||
{targets.length ? targets.map((target) => <div className="surface admin-report-target-row" key={target.channelId}><div><strong>{target.channel.name}</strong><div className="muted">{carrierLabel(target.channel.carrier)} · {target.channel.name}</div></div><Select onChange={(event) => setStatuses((current) => ({ ...current, [target.channelId]: event.target.value }))} options={reportStatusOptions} value={statuses[target.channelId] ?? target.status} /></div>) : <div className="empty-state">该企业应用当前没有配置目标通道。</div>}
|
||||
<Textarea label="修改原因" onChange={(event) => setReason(event.target.value)} placeholder="请输入运营商工单、确认依据或人工处理说明" rows={3} value={reason} />
|
||||
</div>
|
||||
</Modal>;
|
||||
@@ -524,7 +536,7 @@ function DrainageReportModal({ item, onClose, signature }: { item: DrainageInfo;
|
||||
<div><span>电信</span><CarrierReportTag summary={summary?.telecom} /></div>
|
||||
<div className="detail-grid__wide"><span>备注</span><strong>{item.remark || '-'}</strong></div>
|
||||
</div>
|
||||
<div className="page-stack" style={{ marginTop: 16 }}>{targets.map((target) => <div className="surface" key={target.channelId} style={{ display: 'flex', justifyContent: 'space-between', padding: 12 }}><span>{target.channel.name}({target.channel.carrier})</span><CarrierReportTag summary={{ status: target.status, approved: target.status === 'approved' ? 1 : 0, total: 1 }} /></div>)}</div>
|
||||
<div className="page-stack" style={{ marginTop: 16 }}>{targets.map((target) => <div className="surface" key={target.channelId} style={{ display: 'flex', justifyContent: 'space-between', padding: 12 }}><span>{target.channel.name}({carrierLabel(target.channel.carrier)})</span><CarrierReportTag summary={{ status: target.status, approved: target.status === 'approved' ? 1 : 0, total: 1 }} /></div>)}</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -545,7 +557,7 @@ function DrainageReportStatusModal({ item, onClose, onSaved, signature }: { item
|
||||
return <Modal footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={!targets.length || saving} onClick={() => void save()}>{saving ? '保存中...' : '保存状态'}</Button></>} onClose={onClose} open size="xl" title="按通道修改引流信息报备状态">
|
||||
<div className="page-stack"><div className="signature-alert"><Info size={18} /><span>修改的是当前引流信息在具体通道上的真实报备任务,保存后会同步通道报备详情和报备任务页。</span></div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
{targets.length ? targets.map((target) => <div className="surface admin-report-target-row" key={target.channelId}><div><strong>{target.channel.name}</strong><div className="muted">{target.channel.carrier ?? '未标注运营商'} · {target.channel.code}</div></div><Select onChange={(event) => setStatuses((current) => ({ ...current, [target.channelId]: event.target.value }))} options={reportStatusOptions} value={statuses[target.channelId] ?? target.status} /></div>) : <div className="empty-state">当前应用的目标通道没有配置引流信息报备字段。</div>}
|
||||
{targets.length ? targets.map((target) => <div className="surface admin-report-target-row" key={target.channelId}><div><strong>{target.channel.name}</strong><div className="muted">{carrierLabel(target.channel.carrier)} · {target.channel.name}</div></div><Select onChange={(event) => setStatuses((current) => ({ ...current, [target.channelId]: event.target.value }))} options={reportStatusOptions} value={statuses[target.channelId] ?? target.status} /></div>) : <div className="empty-state">当前应用的目标通道没有配置引流信息报备字段。</div>}
|
||||
<Textarea label="修改原因" onChange={(event) => setReason(event.target.value)} rows={3} value={reason} />
|
||||
</div>
|
||||
</Modal>;
|
||||
|
||||
@@ -235,7 +235,10 @@ export function AdminGatewaySubmitExceptionsPage() {
|
||||
<div className="admin-task-filter__actions"><Button icon={<Search size={16} />} onClick={loadData}>查询</Button></div>
|
||||
</div>
|
||||
<div className="surface admin-task-table-card report-task-table-card">
|
||||
<div className="section-heading"><h2>提交异常记录</h2><p className="page-inline-hint">详情中的Gateway命令已由后端脱敏,不返回通道密码。</p></div>
|
||||
<div className="section-heading gateway-exception-list-heading">
|
||||
<div><h2>提交异常记录</h2><p className="page-inline-hint">详情中的 Gateway 命令已由后端脱敏,不返回通道密码。</p></div>
|
||||
<Tag tone="warning">{total} 条</Tag>
|
||||
</div>
|
||||
<Table columns={columns} data={items} emptyText={loading ? '加载中...' : '暂无Gateway提交异常'} pagination={false} rowKey="id" />
|
||||
<Pagination total={total} page={page} totalPages={totalPages} onPageChange={setPage} previousDisabled={page <= 1} nextDisabled={page >= totalPages} onPrevious={() => setPage((current) => Math.max(1, current - 1))} onNext={() => setPage((current) => Math.min(totalPages, current + 1))} />
|
||||
</div>
|
||||
|
||||
@@ -45,7 +45,7 @@ function RecordDetailModal({ record, onClose }: { record: ReportRecord; onClose:
|
||||
<Modal footer={<Button onClick={onClose}>关闭</Button>} onClose={onClose} open size="xl" title={<div className="template-modal-title"><h2>报备记录详情</h2><p>{record.id}</p></div>}>
|
||||
<div className="report-record-detail">
|
||||
<div className="detail-grid">
|
||||
<div><span>任务编号</span><strong>{record.taskId}</strong></div>
|
||||
<div><span>报备任务号</span><strong>{record.taskId}</strong></div>
|
||||
<div><span>通道</span><strong>{record.channel?.name ?? '-'}</strong></div>
|
||||
<div><span>报备类型</span><strong>{isDrainage ? '引流信息' : '签名'}</strong></div>
|
||||
<div><span>报备对象</span><strong>{target ?? '-'}</strong></div>
|
||||
@@ -95,7 +95,7 @@ export function AdminReportRecordsPage() {
|
||||
}), [dateRange.end, dateRange.start, keyword, records, reportType]);
|
||||
|
||||
const columns: Array<TableColumn<ReportRecord>> = [
|
||||
{ key: 'task', title: '任务编号', width: '190px', render: (record) => <strong className="admin-task-id">{record.taskId}</strong> },
|
||||
{ key: 'task', title: '报备任务号', width: '190px', render: (record) => <strong className="admin-task-id">{record.taskId}</strong> },
|
||||
{ key: 'channel', title: '通道名称', width: '180px', render: (record) => record.channel?.name ?? '-' },
|
||||
{ key: 'targetType', title: '变更主体', width: '110px', render: (record) => <Tag tone={record.task?.reportType === 'drainage' ? 'info' : 'neutral'}>{record.task?.reportType === 'drainage' ? '引流信息' : '签名'}</Tag> },
|
||||
{ key: 'target', title: '主体内容', width: '260px', render: (record) => record.task?.reportType === 'drainage' ? <div className="admin-task-enterprise"><strong>{record.task?.signature?.name ?? '-'}</strong><span>{record.task?.drainageInfo?.siteName ?? '-'}</span><span>{record.task?.drainageInfo?.url ?? '-'}</span>{record.task?.drainageInfo?.remark ? <span>{record.task.drainageInfo.remark}</span> : null}</div> : <div className="admin-task-enterprise"><strong>{record.task?.signature?.name ?? '-'}</strong>{record.task?.signature?.purpose ? <span>{record.task.signature.purpose}</span> : null}</div> },
|
||||
@@ -118,7 +118,7 @@ export function AdminReportRecordsPage() {
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
<div className="surface admin-task-filter">
|
||||
<Input label="任务/通道/动作/备注" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入关键字" value={keyword} />
|
||||
<Input label="报备任务号/通道/动作/备注" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入报备任务号、通道、动作或备注" value={keyword} />
|
||||
<Select label="报备类型" onChange={(event) => setReportType(event.target.value)} options={[{ label: '全部类型', value: 'all' }, { label: '签名报备', value: 'signature' }, { label: '引流信息报备', value: 'drainage' }]} value={reportType} />
|
||||
<DateRangeInput label="提交时间" onChange={setDateRange} value={dateRange} />
|
||||
<div className="admin-task-filter__actions">
|
||||
|
||||
@@ -183,7 +183,7 @@ export function AdminReportTasksPage() {
|
||||
}
|
||||
|
||||
const columns: Array<TableColumn<ReportTask>> = [
|
||||
{ key: 'id', title: '任务编号', width: '190px', render: (record) => <strong className="admin-task-id">{record.id}</strong> },
|
||||
{ key: 'id', title: '报备任务号', width: '190px', render: (record) => <strong className="admin-task-id">{record.id}</strong> },
|
||||
{ key: 'scope', title: '通道/报备对象', width: '300px', render: (record) => <div className="admin-task-enterprise"><strong>{record.channel?.name ?? record.channelId}</strong><span>{record.reportType === 'drainage' ? `引流信息 · ${taskTargetLabel(record)}` : `签名 · ${taskTargetLabel(record)}`}</span></div> },
|
||||
{ key: 'status', title: '状态', width: '130px', render: (record) => <Tag tone={(statusMeta[record.status] ?? { tone: 'info' as const }).tone}>{(statusMeta[record.status] ?? { label: record.status }).label}</Tag> },
|
||||
{ key: 'time', title: '创建时间', width: '190px', render: (record) => record.createdAt ?? '-' },
|
||||
@@ -214,7 +214,7 @@ export function AdminReportTasksPage() {
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
<div className="surface admin-task-filter">
|
||||
<Input label="任务/通道/报备对象" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入关键字" value={keyword} />
|
||||
<Input label="报备任务号/通道/报备对象" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入报备任务号、通道或报备对象" value={keyword} />
|
||||
<Select label="报备类型" onChange={(event) => setReportType(event.target.value)} options={[{ label: '全部类型', value: 'all' }, { label: '签名报备', value: 'signature' }, { label: '引流信息报备', value: 'drainage' }]} value={reportType} />
|
||||
<DateRangeInput label="创建时间" onChange={setDateRange} value={dateRange} />
|
||||
<div className="admin-task-filter__actions">
|
||||
|
||||
@@ -133,6 +133,7 @@ export function AdminSmsAuditPage() {
|
||||
width: '54px',
|
||||
render: (record) => <input aria-label={`选择审核任务${record.taskNo}`} checked={selectedIds.includes(record.id)} disabled={record.status !== 'pending_review'} onChange={(event) => setSelectedIds((current) => event.target.checked ? [...new Set([...current, record.id])] : current.filter((id) => id !== record.id))} type="checkbox" />,
|
||||
},
|
||||
{ key: 'taskNo', title: '审核任务号', width: '210px', render: (record) => <strong className="admin-task-id">{record.taskNo}</strong> },
|
||||
{ key: 'sourceType', title: '审核来源', width: '180px', render: (record) => <Tag tone={record.sourceType === 'cmpp_template_mismatch' ? 'warning' : 'info'}>{sourceLabel(record.sourceType)}</Tag> },
|
||||
{ key: 'content', title: '短信内容', render: (record) => <span className="table-long-text">{record.content}</span> },
|
||||
{ key: 'phoneTotal', title: '号码数量', width: '140px', render: (record) => <button className="table-link" onClick={() => { setPhoneTarget(record); setPhoneKeyword(''); setPhonePage(1); }} type="button">{(record._count?.messageRecords || record.phoneTotal).toLocaleString('zh-CN')} · 查看列表</button> },
|
||||
@@ -179,7 +180,7 @@ export function AdminSmsAuditPage() {
|
||||
]}
|
||||
value={status}
|
||||
/>
|
||||
<Input label="短信内容" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入短信内容或审核原因" value={keyword} />
|
||||
<Input label="审核任务号/短信内容" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入审核任务号、短信内容或审核原因" value={keyword} />
|
||||
<Input label={status === 'pending_review' ? '提交日期' : '审核日期'} onChange={(event) => setDate(event.target.value)} placeholder="yyyy-mm-dd" prefix={<CalendarDays size={16} />} value={date} />
|
||||
<div className="audit-filter-actions">
|
||||
<Button icon={<Search size={17} />} onClick={loadData}>查询</Button>
|
||||
@@ -213,6 +214,7 @@ export function AdminSmsAuditPage() {
|
||||
|
||||
{detailTarget ? <Modal footer={<Button onClick={() => setDetailTarget(null)}>关闭</Button>} onClose={() => setDetailTarget(null)} open title="审核任务更多信息">
|
||||
<div className="detail-grid">
|
||||
<div><span>审核任务号</span><strong>{detailTarget.taskNo}</strong></div>
|
||||
<div><span>提交时间</span><strong>{formatDateTime(detailTarget.createdAt)}</strong></div>
|
||||
<div><span>审核人</span><strong>{detailTarget.reviewedBy?.displayName || detailTarget.reviewedBy?.username || '-'}</strong></div>
|
||||
<div><span>审核时间</span><strong>{formatDateTime(detailTarget.reviewedAt)}</strong></div>
|
||||
@@ -222,7 +224,7 @@ export function AdminSmsAuditPage() {
|
||||
</div>
|
||||
</Modal> : null}
|
||||
|
||||
{phoneTarget ? <Modal footer={<Button onClick={() => setPhoneTarget(null)}>关闭</Button>} onClose={() => setPhoneTarget(null)} open size="xl" title={`号码列表 · ${phoneTarget.taskNo}`}>
|
||||
{phoneTarget ? <Modal footer={<Button onClick={() => setPhoneTarget(null)}>关闭</Button>} onClose={() => setPhoneTarget(null)} open size="xl" title={`号码列表 · 审核任务号 ${phoneTarget.taskNo}`}>
|
||||
<div className="page-stack">
|
||||
<div className="audit-filter-grid">
|
||||
<Input label="手机号码" onChange={(event) => setPhoneKeyword(event.target.value)} placeholder="输入完整或部分号码" value={phoneKeyword} />
|
||||
|
||||
@@ -122,7 +122,35 @@ function getCarrierLabel(carrier?: string | null) {
|
||||
return carrier ? (carrierLabelMap[carrier] ?? carrier) : '-';
|
||||
}
|
||||
|
||||
function buildRouteRows(record: SmsMessageRecord): RouteRow[] {
|
||||
function buildRouteRows(record: SmsMessageRecord, segmentAudits: SmsMessageSegmentAudit[]): RouteRow[] {
|
||||
if (segmentAudits.length > 0) {
|
||||
const attempts = new Map<string, SmsMessageSegmentAudit[]>();
|
||||
segmentAudits.forEach((segment) => {
|
||||
const current = attempts.get(segment.submitId) ?? [];
|
||||
current.push(segment);
|
||||
attempts.set(segment.submitId, current);
|
||||
});
|
||||
return Array.from(attempts.entries())
|
||||
.map(([submitId, segments]) => {
|
||||
const ordered = [...segments].sort((left, right) => left.segmentIndex - right.segmentIndex);
|
||||
const sentTimes = ordered.map((segment) => segment.submittedAt).filter(Boolean) as string[];
|
||||
const receiptTimes = ordered.map((segment) => segment.deliveredAt).filter(Boolean) as string[];
|
||||
const receiptCodes = Array.from(new Set(ordered.map((segment) => segment.rawStatus).filter(Boolean)));
|
||||
const submitStatuses = Array.from(new Set(ordered.map((segment) => segment.submitStatus).filter(Boolean)));
|
||||
return {
|
||||
id: submitId,
|
||||
attempt: Math.min(...ordered.map((segment) => segment.attempt)),
|
||||
channel: ordered.find((segment) => segment.channel?.name)?.channel?.name
|
||||
?? ordered.find((segment) => segment.channelId)?.channelId
|
||||
?? '-',
|
||||
sentAt: sentTimes.sort()[0],
|
||||
receiptAt: receiptTimes.sort()[receiptTimes.length - 1],
|
||||
receiptCode: receiptCodes.join(' / ') || undefined,
|
||||
submitStatus: submitStatuses.join(' / ') || undefined,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => left.attempt - right.attempt);
|
||||
}
|
||||
const receipts = record.receiptRecords ?? [];
|
||||
const receiptByGatewayId = new Map<string, SmsReceiptRecord>();
|
||||
receipts.forEach((receipt) => {
|
||||
@@ -208,7 +236,7 @@ function SendDetailModal({
|
||||
segmentLoading: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const routeRows = buildRouteRows(record);
|
||||
const routeRows = buildRouteRows(record, segmentAudits);
|
||||
const sentAccessNumber = `${record.channel?.srcId ?? ''}${record.applicationExtension ?? ''}`;
|
||||
const displayStatus = getRecordStatus(record);
|
||||
const receiptNotice = getReceiptNotice(record);
|
||||
@@ -347,12 +375,26 @@ function SendDetailModal({
|
||||
);
|
||||
}
|
||||
|
||||
function dateKey(date: Date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function defaultSmsRecordDateRange(): DateRangeValue {
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
return { start: dateKey(yesterday), end: dateKey(today) };
|
||||
}
|
||||
|
||||
export function AdminSmsRecordsPage() {
|
||||
const pageSize = 25;
|
||||
const [records, setRecords] = useState<SmsMessageRecord[]>([]);
|
||||
const [enterprise, setEnterprise] = useState('all');
|
||||
const [application, setApplication] = useState('all');
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>(defaultSmsRecordDateRange);
|
||||
const [phoneKeyword, setPhoneKeyword] = useState('');
|
||||
const [contentKeyword, setContentKeyword] = useState('');
|
||||
const [channelKeyword, setChannelKeyword] = useState('');
|
||||
@@ -445,14 +487,15 @@ export function AdminSmsRecordsPage() {
|
||||
const visibleRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
|
||||
function resetFilters() {
|
||||
const defaultDateRange = defaultSmsRecordDateRange();
|
||||
setEnterprise('all');
|
||||
setApplication('all');
|
||||
setDateRange({});
|
||||
setDateRange(defaultDateRange);
|
||||
setPhoneKeyword('');
|
||||
setContentKeyword('');
|
||||
setChannelKeyword('');
|
||||
setStatus('all');
|
||||
loadData({});
|
||||
loadData({ queuedAtFrom: defaultDateRange.start, queuedAtTo: defaultDateRange.end });
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -210,7 +210,7 @@ function splitSignature(content: string) {
|
||||
function TaskDetailTitle({ task }: { task: SmsTask }) {
|
||||
return (
|
||||
<div className="admin-task-detail-title">
|
||||
<h2>任务详情</h2>
|
||||
<h2>发送批次详情</h2>
|
||||
<p>
|
||||
<span>{task.id}</span>
|
||||
<Tag tone={statusTones[task.status]}>{statusLabels[task.status]}</Tag>
|
||||
@@ -252,7 +252,7 @@ function TaskDetailModal({ task, onClose }: { task: SmsTask; onClose: () => void
|
||||
|
||||
<div className="admin-task-detail-grid">
|
||||
<section className="admin-task-card">
|
||||
<h3><Send size={18} />任务信息</h3>
|
||||
<h3><Send size={18} />发送批次信息</h3>
|
||||
<dl className="admin-task-info-list">
|
||||
<div>
|
||||
<dt>企业/应用</dt>
|
||||
@@ -423,7 +423,7 @@ export function AdminSmsTaskProgressPage() {
|
||||
setSelectedTask(null);
|
||||
loadTasks();
|
||||
})
|
||||
.catch((reason: Error) => setError(reason.message || '任务终止失败'));
|
||||
.catch((reason: Error) => setError(reason.message || '发送批次终止失败'));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -436,7 +436,7 @@ export function AdminSmsTaskProgressPage() {
|
||||
</div>
|
||||
|
||||
<div className="surface admin-task-filter">
|
||||
<Input label="任务编号" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入任务编号" value={keyword} />
|
||||
<Input label="发送批次号" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入发送批次号" value={keyword} />
|
||||
<Select
|
||||
label="选择企业"
|
||||
onChange={(event) => {
|
||||
@@ -461,7 +461,7 @@ export function AdminSmsTaskProgressPage() {
|
||||
<table className="ui-table batch-table admin-task-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '170px' }}>任务编号</th>
|
||||
<th style={{ width: '170px' }}>发送批次号</th>
|
||||
<th style={{ width: '180px' }}>企业/应用</th>
|
||||
<th style={{ width: '136px' }}>提交时间</th>
|
||||
<th style={{ width: '130px' }}>号码数/字符数</th>
|
||||
|
||||
@@ -287,7 +287,7 @@ export function AdminSmsUplinkRecordsPage() {
|
||||
},
|
||||
{ key: 'phoneNumber', title: '手机号码', width: '170px', render: (record) => <strong>{record.phoneNumber}</strong> },
|
||||
{ key: 'receivedAt', title: '上行时间', width: '220px', render: (record) => <strong>{getTime(record.receivedAt)}</strong> },
|
||||
{ key: 'content', title: '上行内容', render: (record) => <span className="uplink-content">{record.content}</span> },
|
||||
{ key: 'content', title: '上行内容', width: '360px', render: (record) => <span className="uplink-content" title={record.content}>{record.content}</span> },
|
||||
{ key: 'channel', title: '上行通道', width: '260px', render: (record) => <strong>{record.channel?.name ?? record.channelId}</strong> },
|
||||
{ key: 'accessNo', title: '上行接入号', width: '180px', render: (record) => <strong>{record.destId}</strong> },
|
||||
{ key: 'matchStatus', title: '匹配状态', width: '140px', render: (record) => <strong>{matchStatusText(record.matchStatus)}</strong> },
|
||||
|
||||
@@ -151,13 +151,13 @@ export function ClientBatchTasksPage() {
|
||||
if (!source) return;
|
||||
clientApi.cancelBatchTask(source.backendId)
|
||||
.then(loadTasks)
|
||||
.catch((reason: Error) => setError(reason.message || '任务终止失败'));
|
||||
.catch((reason: Error) => setError(reason.message || '发送批次终止失败'));
|
||||
}
|
||||
|
||||
const columns: Array<TableColumn<BatchTask>> = [
|
||||
{
|
||||
key: 'id',
|
||||
title: '任务编号',
|
||||
title: '发送批次号',
|
||||
width: '140px',
|
||||
render: (record) => (
|
||||
<div className="batch-task-id">
|
||||
@@ -236,12 +236,12 @@ export function ClientBatchTasksPage() {
|
||||
|
||||
<QueryPanel
|
||||
title="查询条件"
|
||||
summary={<>共找到 <strong>{filteredTasks.length}</strong> 条任务记录</>}
|
||||
summary={<>共找到 <strong>{filteredTasks.length}</strong> 个发送批次</>}
|
||||
>
|
||||
<Input
|
||||
label="任务编号"
|
||||
label="发送批次号"
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="输入任务编号搜索"
|
||||
placeholder="输入发送批次号搜索"
|
||||
prefix={<Search size={16} />}
|
||||
value={keyword}
|
||||
/>
|
||||
@@ -310,14 +310,14 @@ export function ClientBatchTasksPage() {
|
||||
onClose={() => setSelectedTask(null)}
|
||||
open={Boolean(selectedTask)}
|
||||
size="xl"
|
||||
title={<DetailTitle title="任务详情" subtitle={selectedTask?.id} />}
|
||||
title={<DetailTitle title="发送批次详情" subtitle={selectedTask?.id} />}
|
||||
>
|
||||
{selectedTask ? (
|
||||
<div className="task-detail">
|
||||
<DetailSection title="基本信息" extra={<Tag tone={statusToneMap[selectedTask.status]}>{statusLabelMap[selectedTask.status]}</Tag>}>
|
||||
<DetailInfoGrid
|
||||
items={[
|
||||
{ label: '任务编号', value: selectedTask.id },
|
||||
{ label: '发送批次号', value: selectedTask.id },
|
||||
{ label: '应用名称', value: selectedTask.applicationName },
|
||||
{ label: '提交时间', value: selectedTask.submittedAt },
|
||||
{
|
||||
@@ -331,7 +331,7 @@ export function ClientBatchTasksPage() {
|
||||
},
|
||||
{ label: '模板字数', value: `${selectedTask.wordCount} 字`, tone: 'primary' },
|
||||
{ label: '单个号码计费条数', value: `${Math.max(1, Math.ceil(selectedTask.wordCount / 70))} 条`, tone: 'primary' },
|
||||
{ label: '任务总号码数', value: `${selectedTask.totalCount.toLocaleString('zh-CN')} 个`, tone: 'primary' },
|
||||
{ label: '批次总号码数', value: `${selectedTask.totalCount.toLocaleString('zh-CN')} 个`, tone: 'primary' },
|
||||
{ label: '总计费条数', value: `${getBillingCount(selectedTask).toLocaleString('zh-CN')} 条`, tone: 'primary' },
|
||||
{ label: '模板内容', value: selectedTask.templateContent, full: true },
|
||||
]}
|
||||
|
||||
@@ -26,7 +26,7 @@ type RecentTaskRow = {
|
||||
};
|
||||
|
||||
const columns: Array<TableColumn<RecentTaskRow>> = [
|
||||
{ key: 'taskNo', title: '批次编号', render: (record) => record.taskNo },
|
||||
{ key: 'taskNo', title: '发送批次号', render: (record) => record.taskNo },
|
||||
{ key: 'scene', title: '发送场景', render: (record) => record.scene },
|
||||
{ key: 'count', title: '发送量', render: (record) => `${record.count.toLocaleString('zh-CN')} 条` },
|
||||
{ key: 'createdAt', title: '创建时间', render: (record) => record.createdAt },
|
||||
|
||||
@@ -177,7 +177,7 @@ export function ClientSendPage() {
|
||||
<Send size={22} />
|
||||
</span>
|
||||
<h1>发送短信</h1>
|
||||
{submittedRecord ? <Tag tone="success">已提交任务 {submittedRecord.taskNo}</Tag> : null}
|
||||
{submittedRecord ? <Tag tone="success">已提交发送批次 {submittedRecord.taskNo}</Tag> : null}
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
|
||||
+139
-14
@@ -2633,6 +2633,13 @@ h3 {
|
||||
|
||||
.uplink-content {
|
||||
color: var(--color-text-strong);
|
||||
display: -webkit-box;
|
||||
line-height: 1.6;
|
||||
min-width: 320px;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.uplink-detail {
|
||||
@@ -8725,30 +8732,113 @@ h3 {
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.downstream-attempt-table {
|
||||
border-top: 0;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 0;
|
||||
.downstream-attempt-timeline {
|
||||
border-top: 0 !important;
|
||||
display: grid !important;
|
||||
gap: var(--space-4) !important;
|
||||
grid-template-columns: 1fr !important;
|
||||
padding: var(--space-2) 0 0 !important;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.downstream-attempt-table > div {
|
||||
.downstream-attempt-timeline::before {
|
||||
background: var(--color-border);
|
||||
bottom: 24px;
|
||||
content: '';
|
||||
left: 19px;
|
||||
position: absolute;
|
||||
top: 28px;
|
||||
width: 2px;
|
||||
}
|
||||
|
||||
.downstream-attempt-card {
|
||||
align-items: start;
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: minmax(110px, .7fr) minmax(190px, 1.1fr) minmax(260px, 1.5fr) minmax(180px, 1fr);
|
||||
min-width: 820px;
|
||||
padding: var(--space-3) 0;
|
||||
grid-template-columns: 40px minmax(0, 1fr);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.downstream-attempt-table .downstream-attempt-table__header {
|
||||
.downstream-attempt-marker {
|
||||
align-items: center;
|
||||
background: var(--color-bg-subtle);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: 50%;
|
||||
color: var(--color-text-muted);
|
||||
display: flex;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
height: 40px;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
width: 40px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.downstream-attempt-table > p {
|
||||
.downstream-attempt-marker--success { background: #ecfdf3; border-color: #86efac; color: #15803d; }
|
||||
.downstream-attempt-marker--danger { background: #fef2f2; border-color: #fca5a5; color: #b91c1c; }
|
||||
.downstream-attempt-marker--info { background: #eff6ff; border-color: #93c5fd; color: #1d4ed8; }
|
||||
|
||||
.downstream-attempt-card__body {
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.downstream-attempt-card__body > header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.downstream-attempt-card dl {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.downstream-attempt-card dl > div {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.downstream-attempt-card dt {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.downstream-attempt-card dd {
|
||||
color: var(--color-text-strong);
|
||||
font-size: var(--font-size-sm);
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.downstream-attempt-card__identifiers dd {
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
}
|
||||
|
||||
.downstream-attempt-result {
|
||||
align-items: center;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
justify-content: space-between;
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.downstream-attempt-result--error {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.downstream-attempt-timeline > p {
|
||||
color: var(--color-text-muted);
|
||||
margin: var(--space-3) 0 0;
|
||||
}
|
||||
@@ -9619,7 +9709,42 @@ h3 {
|
||||
}
|
||||
|
||||
.admin-uplink-table-card .ui-table {
|
||||
min-width: 1160px;
|
||||
min-width: 1500px;
|
||||
}
|
||||
|
||||
.gateway-exception-list-heading {
|
||||
align-items: flex-start;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
gap: var(--space-5);
|
||||
margin: 0;
|
||||
padding: var(--space-5) var(--space-6);
|
||||
}
|
||||
|
||||
.gateway-exception-list-heading > div {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.gateway-exception-list-heading h2,
|
||||
.gateway-exception-list-heading p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.gateway-exception-page .report-task-table-card > .ui-pagination {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding: var(--space-4) var(--space-6);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.downstream-attempt-card dl {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.downstream-attempt-result {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
.admin-uplink-table-card .ui-table th {
|
||||
|
||||
Reference in New Issue
Block a user