feat: polish reporting templates and shared controls
This commit is contained in:
@@ -1055,10 +1055,16 @@ export const adminApi = {
|
||||
listChannels: () => request<AdminChannel[]>('/admin/channels'),
|
||||
listReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<PagedResponse<DailyReconciliationReport>>(withQuery('/admin/reports/reconciliation', query)),
|
||||
exportReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string } = {}) =>
|
||||
requestBlob(withQuery('/admin/reports/reconciliation/export', query)),
|
||||
listProfitReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel'; tenantId?: string; applicationId?: string; channelId?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<PagedResponse<DailyProfitReport> & { dimensionType: 'application' | 'channel' }>(withQuery('/admin/reports/profit', query)),
|
||||
exportProfitReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel'; tenantId?: string; applicationId?: string; channelId?: string } = {}) =>
|
||||
requestBlob(withQuery('/admin/reports/profit/export', query)),
|
||||
listQualityReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel' | 'signature' | 'drainage'; tenantId?: string; applicationId?: string; channelId?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<PagedResponse<DailyQualityReport> & { dimensionType: DailyQualityReport['dimensionType'] }>(withQuery('/admin/reports/quality', query)),
|
||||
exportQualityReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel' | 'signature' | 'drainage'; tenantId?: string; applicationId?: string; channelId?: string } = {}) =>
|
||||
requestBlob(withQuery('/admin/reports/quality/export', query)),
|
||||
createChannel: (body: Partial<AdminChannel> & { passwordCipher?: string; desiredConnections?: number; windowSize?: number }) =>
|
||||
request<AdminChannel>('/admin/channels', { method: 'POST', body: JSON.stringify(body) }),
|
||||
updateChannel: (id: string, body: Partial<AdminChannel> & { passwordCipher?: string; desiredConnections?: number; windowSize?: number }) =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Textarea, Tag, type TableColumn } from '@/components/ui';
|
||||
import { Database, FileCheck2, Link2, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Textarea, Tag } from '@/components/ui';
|
||||
import { adminApi, type CommonReportField, type DictionaryItem } from '@/api/adminApi';
|
||||
|
||||
type DrainageField = DictionaryItem & {
|
||||
@@ -114,25 +114,9 @@ export function AdminDrainageFieldsPage() {
|
||||
.catch((failure: Error) => setError(failure.message || '通用字段删除失败'));
|
||||
}
|
||||
|
||||
const columns = useMemo<Array<TableColumn<DrainageField>>>(() => [
|
||||
{ key: 'code', title: '字段代码', width: '160px', render: (record) => <strong>{record.code}</strong> },
|
||||
{ key: 'name', title: '字段名称', width: '190px', render: (record) => record.name ?? '-' },
|
||||
{ key: 'type', title: '字段类型', width: '160px', render: (record) => <span className="admin-drainage-type">{typeLabels[record.fieldType ?? ''] ?? record.fieldType}</span> },
|
||||
{ key: 'description', title: '描述', render: (record) => record.description ?? '-' },
|
||||
{ key: 'required', title: '是否必填', width: '120px', render: (record) => <Tag tone={record.required ? 'warning' : 'info'}>{record.required ? '必填' : '选填'}</Tag> },
|
||||
{ key: 'usageCount', title: '使用通道数', width: '130px', render: (record) => <Tag tone={(record.usageCount ?? 0) > 0 ? 'warning' : 'neutral'}>{record.usageCount ?? 0}</Tag> },
|
||||
{ key: 'commonUsageCount', title: '通用配置数', width: '130px', render: (record) => <Tag tone={(record.commonUsageCount ?? 0) > 0 ? 'info' : 'neutral'}>{record.commonUsageCount ?? 0}</Tag> },
|
||||
{ key: 'actions', title: '操作', width: '120px', align: 'right', render: (record) => <Button disabled={(record.usageCount ?? 0) > 0 || (record.commonUsageCount ?? 0) > 0} icon={<Trash2 size={14} />} onClick={() => setDeleteTarget(record)} size="sm" variant="danger">删除</Button> },
|
||||
], []);
|
||||
|
||||
const commonColumns = useMemo<Array<TableColumn<CommonReportField>>>(() => [
|
||||
{ key: 'name', title: '字段名称', render: (record) => <strong>{String(record.drainageField.name ?? record.drainageField.code ?? '-')}</strong> },
|
||||
{ key: 'code', title: '字段代码', width: '170px', render: (record) => String(record.drainageField.code ?? '-') },
|
||||
{ key: 'reportType', title: '资料用途', width: '180px', render: (record) => <Tag tone={record.reportType === 'signature' ? 'info' : 'warning'}>{record.reportType === 'signature' ? '签名报备资料' : '引流信息报备资料'}</Tag> },
|
||||
{ key: 'fieldType', title: '字段类型', width: '130px', render: (record) => typeLabels[String(record.drainageField.fieldType ?? '')] ?? String(record.drainageField.fieldType ?? '-') },
|
||||
{ key: 'required', title: '是否必填', width: '120px', render: (record) => <Tag tone={record.required ? 'warning' : 'neutral'}>{record.required ? '必填' : '选填'}</Tag> },
|
||||
{ key: 'actions', title: '操作', width: '120px', align: 'right', render: (record) => <Button icon={<Trash2 size={14} />} onClick={() => setCommonDeleteTarget(record)} size="sm" variant="danger">删除</Button> },
|
||||
], []);
|
||||
const signatureCommon = commonFields.filter((field) => field.reportType === 'signature');
|
||||
const drainageCommon = commonFields.filter((field) => field.reportType === 'drainage');
|
||||
const referencedCount = fields.filter((field) => (field.usageCount ?? 0) > 0 || (field.commonUsageCount ?? 0) > 0).length;
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-system-page admin-drainage-page">
|
||||
@@ -140,10 +124,18 @@ export function AdminDrainageFieldsPage() {
|
||||
<div>
|
||||
<Breadcrumb items={['基础配置', '报备字段库']} />
|
||||
<h1>报备字段库</h1>
|
||||
<p>统一维护签名和引流信息的资料字段,并配置全平台通用报备要求。</p>
|
||||
</div>
|
||||
<Button icon={<Plus size={16} />} onClick={() => setCreating(true)} size="sm">添加字段</Button>
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
<div className="admin-drainage-summary">
|
||||
<article><span><Database size={18} /></span><div><strong>{fields.length}</strong><p>字段总数</p></div></article>
|
||||
<article><span><FileCheck2 size={18} /></span><div><strong>{commonFields.length}</strong><p>通用字段配置</p></div></article>
|
||||
<article><span><Link2 size={18} /></span><div><strong>{referencedCount}</strong><p>已被引用字段</p></div></article>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-drainage-toolbar">
|
||||
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索字段名称、代码或描述..." prefix={<Search size={16} />} value={keyword} />
|
||||
<Select onChange={(event) => setType(event.target.value)} options={typeOptions} value={type} />
|
||||
@@ -151,23 +143,32 @@ export function AdminDrainageFieldsPage() {
|
||||
<Button icon={<Search size={16} />} onClick={() => { setAppliedKeyword(keyword.trim()); setAppliedType(type); }}>查询</Button>
|
||||
<Button onClick={() => { setKeyword(''); setType('all'); setAppliedKeyword(''); setAppliedType('all'); }} variant="ghost">重置</Button>
|
||||
</div>
|
||||
<Button icon={<Plus size={18} />} onClick={() => setCreating(true)}>添加字段</Button>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-system-table-card admin-drainage-table-card">
|
||||
<div className="page-heading">
|
||||
<div className="surface admin-drainage-section">
|
||||
<div className="admin-drainage-section__heading">
|
||||
<div>
|
||||
<h2>通用字段配置</h2>
|
||||
<p>通用字段会与企业应用目标通道配置的字段合并,分别用于签名报备资料和引流信息报备资料。</p>
|
||||
<p>企业新增或编辑签名、引流信息时必须按这里的配置填写,通道字段也可以直接引用。</p>
|
||||
</div>
|
||||
<Button icon={<Plus size={18} />} onClick={() => setConfiguringCommon(true)}>配置通用字段</Button>
|
||||
<Button icon={<Plus size={16} />} onClick={() => setConfiguringCommon(true)} size="sm" variant="secondary">配置通用字段</Button>
|
||||
</div>
|
||||
<div className="admin-drainage-common-grid">
|
||||
<CommonFieldGroup fields={signatureCommon} label="签名报备资料" onDelete={setCommonDeleteTarget} tone="info" />
|
||||
<CommonFieldGroup fields={drainageCommon} label="引流信息报备资料" onDelete={setCommonDeleteTarget} tone="warning" />
|
||||
</div>
|
||||
<Table columns={commonColumns} data={commonFields} emptyText="暂无通用字段配置" rowKey="id" />
|
||||
</div>
|
||||
|
||||
<div className="surface admin-system-table-card admin-drainage-table-card">
|
||||
<div className="page-heading"><div><h2>字段库</h2><p>字段定义被通道或通用配置引用后,需要先删除对应配置才能删除字段。</p></div></div>
|
||||
<Table columns={columns} data={filteredFields} emptyText="暂无字段" rowKey="id" />
|
||||
<div className="surface admin-drainage-section">
|
||||
<div className="admin-drainage-section__heading"><div><h2>字段定义</h2><p>共 {filteredFields.length} 个结果。已被通道或通用配置引用的字段不能删除。</p></div></div>
|
||||
{filteredFields.length ? <div className="admin-drainage-field-grid">{filteredFields.map((field) => {
|
||||
const locked = (field.usageCount ?? 0) > 0 || (field.commonUsageCount ?? 0) > 0;
|
||||
return <article className="admin-drainage-field-card" key={field.id}>
|
||||
<div className="admin-drainage-field-card__top"><span className="admin-drainage-type">{typeLabels[field.fieldType ?? ''] ?? field.fieldType}</span><Button aria-label={`删除${field.name}`} disabled={locked} icon={<Trash2 size={14} />} iconOnly onClick={() => setDeleteTarget(field)} size="sm" variant="ghost">删除</Button></div>
|
||||
<h3>{field.name ?? '-'}</h3><code>{field.code}</code><p>{field.description || '暂无字段说明'}</p>
|
||||
<div className="admin-drainage-field-card__meta"><span>通道引用 <strong>{field.usageCount ?? 0}</strong></span><span>通用配置 <strong>{field.commonUsageCount ?? 0}</strong></span></div>
|
||||
</article>;
|
||||
})}</div> : <div className="admin-drainage-empty">没有符合筛选条件的字段</div>}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
@@ -235,3 +236,7 @@ export function AdminDrainageFieldsPage() {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CommonFieldGroup({ fields, label, onDelete, tone }: { fields: CommonReportField[]; label: string; onDelete: (field: CommonReportField) => void; tone: 'info' | 'warning' }) {
|
||||
return <section className="admin-drainage-common-group"><div className="admin-drainage-common-group__title"><Tag tone={tone}>{label}</Tag><span>{fields.length} 项</span></div>{fields.length ? <div className="admin-drainage-common-list">{fields.map((field) => <div key={field.id}><div><strong>{String(field.drainageField.name ?? field.drainageField.code)}</strong><span>{String(field.drainageField.code)} · {typeLabels[String(field.drainageField.fieldType ?? '')] ?? '-'}</span></div><Tag tone={field.required ? 'warning' : 'neutral'}>{field.required ? '必填' : '选填'}</Tag><Button aria-label="删除通用字段" icon={<Trash2 size={14} />} iconOnly onClick={() => onDelete(field)} size="sm" variant="ghost">删除</Button></div>)}</div> : <p className="admin-drainage-common-empty">暂未配置字段</p>}</section>;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Edit3, Eye, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type TenantOption } from '@/api/adminApi';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
|
||||
import { replaceLeadingSmsSignature } from '@/utils/smsSignature';
|
||||
|
||||
type TemplateFormState = {
|
||||
tenantId: string;
|
||||
@@ -85,17 +86,25 @@ function TemplateFormModal({
|
||||
const [customVariable, setCustomVariable] = useState('');
|
||||
const [variablesOpen, setVariablesOpen] = useState(false);
|
||||
const contentRef = useRef<HTMLTextAreaElement>(null);
|
||||
const initialSignature = signatures.find((signature) => signature.id === item?.signatureId);
|
||||
const initialContent = item?.signatureId
|
||||
? replaceLeadingSmsSignature(item.content, initialSignature?.name)
|
||||
: item?.content ?? '';
|
||||
const [form, setForm] = useState<TemplateFormState>({
|
||||
tenantId: item?.tenantId ?? '',
|
||||
applicationId: item?.applicationId ?? '',
|
||||
signatureId: item?.signatureId ?? '',
|
||||
name: item?.name ?? '',
|
||||
content: item?.content ?? '',
|
||||
content: initialContent,
|
||||
category: item?.category ?? '行业通知',
|
||||
variables: item?.variables?.map((variable) => ({ name: variable.name, example: variable.example ?? undefined, required: variable.required ?? true })) ?? [],
|
||||
});
|
||||
const tenantApplications = applications.filter((application) => application.tenantId === form.tenantId && application.status !== 'deleted');
|
||||
const tenantSignatures = signatures.filter((signature) => signature.tenantId === form.tenantId && signature.auditStatus !== 'deleted');
|
||||
const tenantSignatures = signatures.filter((signature) => (
|
||||
signature.tenantId === form.tenantId
|
||||
&& signature.auditStatus !== 'deleted'
|
||||
&& (!signature.applicationId || signature.applicationId === form.applicationId)
|
||||
));
|
||||
const currentVariables = form.variables.length ? form.variables : extractVariables(form.content);
|
||||
|
||||
function update<Key extends keyof TemplateFormState>(key: Key, value: TemplateFormState[Key]) {
|
||||
@@ -106,6 +115,14 @@ function TemplateFormModal({
|
||||
setForm((current) => ({ ...current, content, variables: extractVariables(content) }));
|
||||
}
|
||||
|
||||
function selectSignature(signatureId: string) {
|
||||
const signature = tenantSignatures.find((candidate) => candidate.id === signatureId);
|
||||
setForm((current) => {
|
||||
const content = replaceLeadingSmsSignature(current.content, signature?.name);
|
||||
return { ...current, signatureId, content, variables: extractVariables(content) };
|
||||
});
|
||||
}
|
||||
|
||||
function insertVariable(name: string) {
|
||||
const normalized = name.trim();
|
||||
if (!normalized) {
|
||||
@@ -132,7 +149,7 @@ function TemplateFormModal({
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
<Button disabled={!form.tenantId || !form.applicationId || !form.name || !form.content.trim()} onClick={() => onSubmit({ ...form, variables: currentVariables })}>保存</Button>
|
||||
<Button disabled={!form.tenantId || !form.applicationId || !form.signatureId || !form.name || !form.content.trim()} onClick={() => onSubmit({ ...form, variables: currentVariables })}>保存</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
@@ -164,19 +181,21 @@ function TemplateFormModal({
|
||||
/>
|
||||
<Select
|
||||
label="签名"
|
||||
onChange={(event) => update('signatureId', event.target.value)}
|
||||
onChange={(event) => selectSignature(event.target.value)}
|
||||
options={[
|
||||
{ label: '不绑定签名', value: '' },
|
||||
{ label: '请选择签名', value: '' },
|
||||
...tenantSignatures.map((signature) => ({ label: signature.name, value: signature.id })),
|
||||
]}
|
||||
required
|
||||
value={form.signatureId}
|
||||
/>
|
||||
<Input label="模板名称" onChange={(event) => update('name', event.target.value)} placeholder="请输入模板名称" required value={form.name} />
|
||||
<Input label="模板分类" onChange={(event) => update('category', event.target.value)} placeholder="行业通知/营销推广/验证码" value={form.category} />
|
||||
<Textarea
|
||||
hint="模板内容必须以所选签名开头;选择或切换签名时系统会自动填入或替换完整签名,例如:【XX公司】验证码为${code}。"
|
||||
label="模板内容"
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
placeholder="例如:尊敬的${name},您的验证码为${code}。"
|
||||
placeholder="请选择签名后填写正文,例如:尊敬的${name},您的验证码为${code}。"
|
||||
required
|
||||
rows={8}
|
||||
ref={contentRef}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { Download, Search } from 'lucide-react';
|
||||
import { adminApi, type AdminChannel, type DailyProfitReport, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, DateRangeInput, Pagination, Select, Tag, type DateRangeValue } from '@/components/ui';
|
||||
import { formatCents } from '@/utils/currency';
|
||||
@@ -21,6 +21,7 @@ export function AdminProfitReportsPage() {
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([adminApi.listTenants(), adminApi.listEnterpriseApplications(), adminApi.listChannels()])
|
||||
@@ -45,6 +46,13 @@ export function AdminProfitReportsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function exportData() {
|
||||
setExporting(true); setError('');
|
||||
try { downloadBlob(await adminApi.exportProfitReports({ dateFrom: dateRange.start, dateTo: dateRange.end, dimensionType, tenantId: tenantId || undefined, applicationId: applicationId || undefined, channelId: channelId || undefined }), '利润报表.csv'); }
|
||||
catch (failure) { setError(failure instanceof Error ? failure.message : '利润报表导出失败'); }
|
||||
finally { setExporting(false); }
|
||||
}
|
||||
|
||||
const availableApplications = useMemo(() => applications.filter((application) => !tenantId || application.tenantId === tenantId), [applications, tenantId]);
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
@@ -52,7 +60,7 @@ export function AdminProfitReportsPage() {
|
||||
<section className="page-stack">
|
||||
<div className="page-heading">
|
||||
<div><Breadcrumb items={['报表对账', '利润报表']} /><h1>利润报表</h1></div>
|
||||
<Tag tone="info">T+1 生成 · 每日重算 T-4~T-1</Tag>
|
||||
<div className="page-actions"><Button disabled={exporting} icon={<Download size={16} />} onClick={() => void exportData()} size="sm" variant="secondary">{exporting ? '导出中...' : '导出报表'}</Button><Tag tone="info">T+1 生成 · 每日重算 T-4~T-1</Tag></div>
|
||||
</div>
|
||||
|
||||
<div className="surface" style={{ display: 'grid', gap: 16, gridTemplateColumns: 'minmax(270px, 1.3fr) minmax(180px, .8fr) minmax(210px, 1fr) minmax(220px, 1fr) auto', padding: 20, alignItems: 'end' }}>
|
||||
@@ -92,3 +100,5 @@ function defaultDateRange(): DateRangeValue {
|
||||
function localDate(value: Date) {
|
||||
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, fileName: string) { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = fileName; anchor.click(); URL.revokeObjectURL(url); }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { Download, Search } from 'lucide-react';
|
||||
import { adminApi, type AdminChannel, type DailyQualityReport, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, DateRangeInput, Pagination, Select, Tabs, Tag, type DateRangeValue } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
@@ -24,6 +24,7 @@ export function AdminQualityReportsPage() {
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([adminApi.listTenants(), adminApi.listEnterpriseApplications(), adminApi.listChannels()])
|
||||
@@ -54,6 +55,13 @@ export function AdminQualityReportsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function exportData() {
|
||||
setExporting(true); setError('');
|
||||
try { downloadBlob(await adminApi.exportQualityReports({ dimensionType: dimension, dateFrom: dateRange.start, dateTo: dateRange.end, tenantId: dimension === 'channel' ? undefined : tenantId || undefined, applicationId: dimension === 'channel' ? undefined : applicationId || undefined, channelId: dimension === 'channel' ? channelId || undefined : undefined }), '发送质量报表.csv'); }
|
||||
catch (failure) { setError(failure instanceof Error ? failure.message : '发送质量报表导出失败'); }
|
||||
finally { setExporting(false); }
|
||||
}
|
||||
|
||||
const availableApplications = useMemo(() => applications.filter((application) => !tenantId || application.tenantId === tenantId), [applications, tenantId]);
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
@@ -89,7 +97,7 @@ export function AdminQualityReportsPage() {
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="page-heading"><div><Breadcrumb items={['报表对账', '发送质量报表']} /><h1>发送质量报表</h1></div><Tag tone="info">剔除最慢 5% · 每日重算 T-4~T-1</Tag></div>
|
||||
<div className="page-heading"><div><Breadcrumb items={['报表对账', '发送质量报表']} /><h1>发送质量报表</h1></div><div className="page-actions"><Button disabled={exporting} icon={<Download size={16} />} onClick={() => void exportData()} size="sm" variant="secondary">{exporting ? '导出中...' : '导出报表'}</Button><Tag tone="info">剔除最慢 5% · 每日重算 T-4~T-1</Tag></div></div>
|
||||
<Tabs value={dimension} onChange={changeDimension} items={(Object.keys(dimensionLabels) as QualityDimension[]).map((value) => ({ value, label: dimensionLabels[value], content: reportPanel }))} />
|
||||
</section>
|
||||
);
|
||||
@@ -111,3 +119,5 @@ function defaultDateRange(): DateRangeValue {
|
||||
function localDate(value: Date) {
|
||||
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, fileName: string) { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = fileName; anchor.click(); URL.revokeObjectURL(url); }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { Download, Search } from 'lucide-react';
|
||||
import { adminApi, type DailyReconciliationReport, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, DateRangeInput, Pagination, Select, Tag, type DateRangeValue } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
@@ -17,6 +17,7 @@ export function AdminReconciliationReportsPage() {
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([adminApi.listTenants(), adminApi.listEnterpriseApplications()])
|
||||
@@ -49,6 +50,13 @@ export function AdminReconciliationReportsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function exportData() {
|
||||
setExporting(true); setError('');
|
||||
try { downloadBlob(await adminApi.exportReconciliationReports({ dateFrom: dateRange.start, dateTo: dateRange.end, tenantId: tenantId || undefined, applicationId: applicationId || undefined }), '对账单.csv'); }
|
||||
catch (failure) { setError(failure instanceof Error ? failure.message : '对账单导出失败'); }
|
||||
finally { setExporting(false); }
|
||||
}
|
||||
|
||||
const availableApplications = useMemo(
|
||||
() => applications.filter((application) => !tenantId || application.tenantId === tenantId),
|
||||
[applications, tenantId],
|
||||
@@ -59,7 +67,7 @@ export function AdminReconciliationReportsPage() {
|
||||
<section className="page-stack">
|
||||
<div className="page-heading">
|
||||
<div><Breadcrumb items={['报表对账', '对账单']} /><h1>对账单</h1></div>
|
||||
<Tag tone="info">T+1 生成 · 每日重算 T-4~T-1</Tag>
|
||||
<div className="page-actions"><Button disabled={exporting} icon={<Download size={16} />} onClick={() => void exportData()} size="sm" variant="secondary">{exporting ? '导出中...' : '导出报表'}</Button><Tag tone="info">T+1 生成 · 每日重算 T-4~T-1</Tag></div>
|
||||
</div>
|
||||
|
||||
<div className="surface" style={{ display: 'grid', gap: 16, gridTemplateColumns: 'minmax(280px, 1.4fr) minmax(200px, 1fr) minmax(220px, 1fr) auto', padding: 20, alignItems: 'end' }}>
|
||||
@@ -98,3 +106,5 @@ function defaultDateRange(): DateRangeValue {
|
||||
function localDate(value: Date) {
|
||||
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, fileName: string) { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = fileName; anchor.click(); URL.revokeObjectURL(url); }
|
||||
|
||||
@@ -198,13 +198,13 @@ export function AdminUsersPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-system-toolbar">
|
||||
<div className="surface admin-system-toolbar admin-user-toolbar">
|
||||
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索姓名、邮箱、手机号、角色或企业" prefix={<Search size={16} />} value={keyword} />
|
||||
<div className="admin-system-toolbar__actions">
|
||||
<Button icon={<Search size={16} />} onClick={() => setAppliedKeyword(keyword.trim())}>查询</Button>
|
||||
<Button onClick={() => { setKeyword(''); setAppliedKeyword(''); }} variant="ghost">重置</Button>
|
||||
</div>
|
||||
<Button icon={<Plus size={16} />} onClick={openCreate}>新增用户</Button>
|
||||
<Button icon={<Plus size={16} />} onClick={openCreate} size="sm">新增用户</Button>
|
||||
</div>
|
||||
{error ? <div className="surface empty-state">{error}</div> : null}
|
||||
<div className="surface admin-system-table-card">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Edit3, MessageSquare, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
||||
import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate } from '@/api/adminApi';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { replaceLeadingSmsSignature } from '@/utils/smsSignature';
|
||||
|
||||
type TemplateVariable = {
|
||||
name: string;
|
||||
@@ -73,12 +74,16 @@ function TemplateModal({
|
||||
const [customVariable, setCustomVariable] = useState('');
|
||||
const [variablesOpen, setVariablesOpen] = useState(false);
|
||||
const contentRef = useRef<HTMLTextAreaElement>(null);
|
||||
const initialSignature = signatures.find((signature) => signature.id === item?.signatureId);
|
||||
const initialContent = item?.signatureId
|
||||
? replaceLeadingSmsSignature(item.content, initialSignature?.name)
|
||||
: item?.content ?? '';
|
||||
const [form, setForm] = useState<TemplateFormState>({
|
||||
applicationId: item?.applicationId ?? '',
|
||||
signatureId: item?.signatureId ?? '',
|
||||
name: item?.name ?? '',
|
||||
category: item?.category ?? '行业通知',
|
||||
content: item?.content ?? '',
|
||||
content: initialContent,
|
||||
variables: item?.variables?.map((variable) => ({ name: variable.name, example: variable.example ?? undefined, required: variable.required ?? true })) ?? [],
|
||||
});
|
||||
const application = applications.find((candidate) => candidate.id === form.applicationId);
|
||||
@@ -97,6 +102,14 @@ function TemplateModal({
|
||||
setForm((current) => ({ ...current, content, variables: extractVariables(content) }));
|
||||
}
|
||||
|
||||
function selectSignature(signatureId: string) {
|
||||
const signature = availableSignatures.find((candidate) => candidate.id === signatureId);
|
||||
setForm((current) => {
|
||||
const content = replaceLeadingSmsSignature(current.content, signature?.name);
|
||||
return { ...current, signatureId, content, variables: extractVariables(content) };
|
||||
});
|
||||
}
|
||||
|
||||
function insertVariable(name: string) {
|
||||
const normalized = name.trim();
|
||||
if (!normalized) return;
|
||||
@@ -120,7 +133,7 @@ function TemplateModal({
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
<Button disabled={!form.applicationId || !form.name || !form.content.trim()} onClick={() => onSubmit({ ...form, variables })}>提交审核</Button>
|
||||
<Button disabled={!form.applicationId || !form.signatureId || !form.name || !form.content.trim()} onClick={() => onSubmit({ ...form, variables })}>提交审核</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
@@ -137,13 +150,23 @@ function TemplateModal({
|
||||
/>
|
||||
<Select
|
||||
label="短信签名"
|
||||
onChange={(event) => update('signatureId', event.target.value)}
|
||||
options={[{ label: '不绑定签名', value: '' }, ...availableSignatures.map((signature) => ({ label: signature.name, value: signature.id }))]}
|
||||
onChange={(event) => selectSignature(event.target.value)}
|
||||
options={[{ label: '请选择签名', value: '' }, ...availableSignatures.map((signature) => ({ label: signature.name, value: signature.id }))]}
|
||||
required
|
||||
value={form.signatureId}
|
||||
/>
|
||||
<Input label="模板名称" onChange={(event) => update('name', event.target.value)} placeholder="请输入模板名称" value={form.name} />
|
||||
<Input label="模板分类" onChange={(event) => update('category', event.target.value)} placeholder="行业通知/营销推广/验证码" value={form.category} />
|
||||
<Textarea label="模板内容" onChange={(event) => setContent(event.target.value)} placeholder="变量格式:${code}" ref={contentRef} rows={6} value={form.content} />
|
||||
<Textarea
|
||||
hint="模板内容必须以所选签名开头;选择或切换签名时系统会自动填入或替换完整签名,例如:【XX公司】验证码为${code}。"
|
||||
label="模板内容"
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
placeholder="请选择签名后填写正文,变量格式:${code}"
|
||||
ref={contentRef}
|
||||
required
|
||||
rows={6}
|
||||
value={form.content}
|
||||
/>
|
||||
<div className="template-form-meta">
|
||||
<button onClick={() => setVariablesOpen((current) => !current)} type="button">
|
||||
<Plus size={16} /> {variablesOpen ? '收起变量面板' : '插入变量'}
|
||||
|
||||
@@ -158,7 +158,7 @@ export function ClientUsersPage() {
|
||||
<span className="sms-send-title__icon"><Users size={22} /></span>
|
||||
<h1>用户管理</h1>
|
||||
</div>
|
||||
<Button icon={<Plus size={18} />} onClick={() => openEditor()}>添加用户</Button>
|
||||
<Button icon={<Plus size={16} />} onClick={() => openEditor()} size="sm">添加用户</Button>
|
||||
</div>
|
||||
|
||||
<div className="system-filter-row">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SelectHTMLAttributes } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { CSSProperties, SelectHTMLAttributes } from 'react';
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ChevronDown, Search } from 'lucide-react';
|
||||
|
||||
export type SelectOption = {
|
||||
@@ -22,6 +23,7 @@ type SelectProps = NativeSelectProps & {
|
||||
placeholder?: string;
|
||||
searchable?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
dropdownPortal?: boolean;
|
||||
onChange?: (event: { target: { value: string } }) => void;
|
||||
};
|
||||
|
||||
@@ -39,12 +41,15 @@ export function Select({
|
||||
placeholder,
|
||||
searchable,
|
||||
searchPlaceholder,
|
||||
dropdownPortal = true,
|
||||
required,
|
||||
...props
|
||||
}: SelectProps) {
|
||||
const selectId = id ?? props.name;
|
||||
const rootRef = useRef<HTMLLabelElement | null>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [portalStyle, setPortalStyle] = useState<CSSProperties | null>(null);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [internalValue, setInternalValue] = useState(defaultValue ?? value ?? options[0]?.value ?? '');
|
||||
const selectedValue = value ?? internalValue;
|
||||
@@ -61,7 +66,7 @@ export function Select({
|
||||
|
||||
useEffect(() => {
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target as Node) && !dropdownRef.current?.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
setSearchKeyword('');
|
||||
}
|
||||
@@ -71,6 +76,40 @@ export function Select({
|
||||
return () => document.removeEventListener('pointerdown', handlePointerDown);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !dropdownPortal) {
|
||||
setPortalStyle(null);
|
||||
return;
|
||||
}
|
||||
|
||||
function updatePosition() {
|
||||
const trigger = rootRef.current?.querySelector<HTMLElement>('.ui-select');
|
||||
if (!trigger) return;
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const gap = 6;
|
||||
const spaceBelow = window.innerHeight - rect.bottom - gap - 8;
|
||||
const spaceAbove = rect.top - gap - 8;
|
||||
const openAbove = spaceBelow < 220 && spaceAbove > spaceBelow;
|
||||
const available = openAbove ? spaceAbove : spaceBelow;
|
||||
setPortalStyle({
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
maxHeight: Math.min(320, Math.max(140, available)),
|
||||
...(openAbove
|
||||
? { bottom: window.innerHeight - rect.top + gap, top: 'auto' }
|
||||
: { top: rect.bottom + gap, bottom: 'auto' }),
|
||||
});
|
||||
}
|
||||
|
||||
updatePosition();
|
||||
window.addEventListener('resize', updatePosition);
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
};
|
||||
}, [dropdownPortal, open]);
|
||||
|
||||
function selectOption(nextValue: string) {
|
||||
setInternalValue(nextValue);
|
||||
onChange?.({ target: { value: nextValue } });
|
||||
@@ -78,6 +117,28 @@ export function Select({
|
||||
setSearchKeyword('');
|
||||
}
|
||||
|
||||
const dropdown = (
|
||||
<div
|
||||
className={['ui-select__dropdown', dropdownPortal ? 'ui-select__dropdown--portal' : ''].filter(Boolean).join(' ')}
|
||||
ref={dropdownRef}
|
||||
role="listbox"
|
||||
style={dropdownPortal ? portalStyle ?? { visibility: 'hidden' } : undefined}
|
||||
>
|
||||
{searchEnabled ? (
|
||||
<label className="ui-select__search">
|
||||
<Search size={15} />
|
||||
<input autoFocus onChange={(event) => setSearchKeyword(event.target.value)} onKeyDown={(event) => event.stopPropagation()} placeholder={searchPlaceholder ?? '输入名称搜索'} value={searchKeyword} />
|
||||
</label>
|
||||
) : null}
|
||||
{visibleOptions.map((option) => (
|
||||
<button aria-selected={option.value === selectedValue} key={option.value} onClick={() => selectOption(option.value)} role="option" type="button">
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
{visibleOptions.length === 0 ? <span className="ui-select__empty">无匹配选项</span> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<label
|
||||
className={['ui-field', className].filter(Boolean).join(' ')}
|
||||
@@ -114,28 +175,7 @@ export function Select({
|
||||
</span>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="ui-select__dropdown" role="listbox">
|
||||
{searchEnabled ? (
|
||||
<label className="ui-select__search">
|
||||
<Search size={15} />
|
||||
<input autoFocus onChange={(event) => setSearchKeyword(event.target.value)} onKeyDown={(event) => event.stopPropagation()} placeholder={searchPlaceholder ?? '输入名称搜索'} value={searchKeyword} />
|
||||
</label>
|
||||
) : null}
|
||||
{visibleOptions.map((option) => (
|
||||
<button
|
||||
aria-selected={option.value === selectedValue}
|
||||
key={option.value}
|
||||
onClick={() => selectOption(option.value)}
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
{visibleOptions.length === 0 ? <span className="ui-select__empty">无匹配选项</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{open ? (dropdownPortal ? createPortal(dropdown, document.body) : dropdown) : null}
|
||||
</span>
|
||||
{error ? <span className="ui-field__error">{error}</span> : null}
|
||||
{!error && hint ? <span className="ui-field__hint">{hint}</span> : null}
|
||||
|
||||
@@ -647,11 +647,28 @@
|
||||
}
|
||||
|
||||
.ui-select__dropdown button {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text);
|
||||
display: flex;
|
||||
font-size: var(--font-size-md);
|
||||
height: 36px;
|
||||
justify-content: flex-start;
|
||||
padding: 0 var(--space-3);
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ui-select__dropdown--portal {
|
||||
bottom: auto;
|
||||
left: auto;
|
||||
max-height: 320px;
|
||||
position: fixed;
|
||||
right: auto;
|
||||
top: auto;
|
||||
z-index: calc(var(--z-modal) + 1);
|
||||
}
|
||||
|
||||
.ui-select__dropdown button:hover {
|
||||
|
||||
+46
-1
@@ -9261,6 +9261,15 @@ h3 {
|
||||
grid-template-columns: minmax(360px, 1fr) auto;
|
||||
}
|
||||
|
||||
.admin-user-toolbar {
|
||||
grid-template-columns: minmax(360px, 1fr) auto auto;
|
||||
}
|
||||
|
||||
.admin-user-toolbar > .ui-button {
|
||||
justify-self: end;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.phone-segment-overview {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
@@ -9442,9 +9451,38 @@ h3 {
|
||||
align-items: end;
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
grid-template-columns: minmax(420px, 1fr) minmax(180px, 220px) minmax(180px, 220px) auto;
|
||||
grid-template-columns: minmax(320px, 1fr) minmax(160px, 220px) auto;
|
||||
}
|
||||
|
||||
.admin-drainage-page .page-heading > div > p { color: var(--color-text-muted); margin: 6px 0 0; }
|
||||
.admin-drainage-summary { display: grid; gap: 16px; grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.admin-drainage-summary article { align-items: center; background: linear-gradient(135deg, #fff, var(--color-bg-subtle)); border: 1px solid var(--color-border); border-radius: var(--radius-lg); display: flex; gap: 14px; min-height: 96px; padding: 20px; }
|
||||
.admin-drainage-summary article > span { align-items: center; background: #eef4ff; border-radius: 12px; color: var(--color-primary); display: flex; height: 42px; justify-content: center; width: 42px; }
|
||||
.admin-drainage-summary strong { color: var(--color-text-strong); font-size: 24px; }
|
||||
.admin-drainage-summary p { color: var(--color-text-muted); margin: 3px 0 0; }
|
||||
.admin-drainage-section { padding: 22px; }
|
||||
.admin-drainage-section__heading { align-items: flex-start; display: flex; gap: 20px; justify-content: space-between; margin-bottom: 18px; }
|
||||
.admin-drainage-section__heading h2 { margin: 0 0 6px; }
|
||||
.admin-drainage-section__heading p { color: var(--color-text-muted); margin: 0; }
|
||||
.admin-drainage-common-grid { display: grid; gap: 16px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.admin-drainage-common-group { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: var(--radius-md); min-width: 0; padding: 16px; }
|
||||
.admin-drainage-common-group__title { align-items: center; display: flex; justify-content: space-between; margin-bottom: 12px; }
|
||||
.admin-drainage-common-group__title > span, .admin-drainage-common-empty { color: var(--color-text-muted); font-size: var(--font-size-sm); }
|
||||
.admin-drainage-common-list { display: grid; gap: 8px; }
|
||||
.admin-drainage-common-list > div { align-items: center; background: #fff; border: 1px solid var(--color-border); border-radius: var(--radius-sm); display: grid; gap: 10px; grid-template-columns: minmax(0, 1fr) auto auto; padding: 11px 12px; }
|
||||
.admin-drainage-common-list strong, .admin-drainage-common-list span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.admin-drainage-common-list span { color: var(--color-text-muted); font-size: 12px; margin-top: 3px; }
|
||||
.admin-drainage-field-grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
|
||||
.admin-drainage-field-card { border: 1px solid var(--color-border); border-radius: var(--radius-md); display: flex; flex-direction: column; min-height: 220px; padding: 16px; transition: border-color .2s, box-shadow .2s, transform .2s; }
|
||||
.admin-drainage-field-card:hover { border-color: #b8c9eb; box-shadow: 0 8px 24px rgba(27, 55, 100, .08); transform: translateY(-1px); }
|
||||
.admin-drainage-field-card__top { align-items: center; display: flex; justify-content: space-between; }
|
||||
.admin-drainage-field-card h3 { font-size: 17px; margin: 15px 0 5px; }
|
||||
.admin-drainage-field-card code { color: var(--color-primary); font-size: 13px; }
|
||||
.admin-drainage-field-card > p { color: var(--color-text-muted); flex: 1; line-height: 1.6; margin: 12px 0; }
|
||||
.admin-drainage-field-card__meta { border-top: 1px solid var(--color-border); color: var(--color-text-muted); display: flex; font-size: 13px; gap: 18px; padding-top: 12px; }
|
||||
.admin-drainage-field-card__meta strong { color: var(--color-text-strong); }
|
||||
.admin-drainage-empty { color: var(--color-text-muted); padding: 42px; text-align: center; }
|
||||
|
||||
.admin-drainage-type {
|
||||
align-items: center;
|
||||
background: var(--color-bg-subtle);
|
||||
@@ -9458,6 +9496,13 @@ h3 {
|
||||
padding: 0 var(--space-3);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.admin-drainage-summary, .admin-drainage-common-grid { grid-template-columns: 1fr; }
|
||||
.admin-drainage-toolbar { grid-template-columns: 1fr; }
|
||||
.admin-drainage-section__heading { align-items: stretch; flex-direction: column; }
|
||||
.admin-user-toolbar { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.admin-drainage-actions .ui-button--ghost {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
const LEADING_SMS_SIGNATURE = /^【[^】]+】/;
|
||||
|
||||
export function formatSmsSignature(name?: string | null) {
|
||||
const innerName = (name ?? '').trim().replace(/^[【\[]+|[】\]]+$/g, '').trim();
|
||||
return innerName ? `【${innerName}】` : '';
|
||||
}
|
||||
|
||||
export function replaceLeadingSmsSignature(content: string, signatureName?: string | null) {
|
||||
const body = content.replace(LEADING_SMS_SIGNATURE, '');
|
||||
return `${formatSmsSignature(signatureName)}${body}`;
|
||||
}
|
||||
Reference in New Issue
Block a user