feat: 增加模板通道拒收策略并修复运营页面

This commit is contained in:
hectorzhao
2026-09-20 15:09:57 +08:00
parent c20c2246b2
commit b24cd7c08d
38 changed files with 2485 additions and 613 deletions
+2 -12
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { QualityStatusBar } from './QualityStatusBar';
import { BarChart3, Eye, Search, X } from 'lucide-react';
import {
adminApi,
@@ -229,7 +230,7 @@ function AnalyticsPanel({ kind }: { kind: string }) {
key: 'successRate',
title: '成功率',
width: '170px',
render: (record) => <QualityRate value={record.successRate} />,
render: (record) => <QualityStatusBar metric={record} />,
},
{
key: 'averageArrivalMs',
@@ -928,17 +929,6 @@ function MatrixMetric({
);
}
function QualityRate({ value }: { value: number }) {
return (
<div className="signature-quality-rate">
<div>
<span style={{ width: `${Math.min(100, Math.max(0, value))}%` }} />
</div>
<strong className={successRateClassName(value)}>{value.toFixed(1)}%</strong>
</div>
);
}
function normalizeCarrier(value: string) {
const normalized = value.toLowerCase();
if (['mobile', 'cmcc', '移动'].includes(normalized)) return 'mobile';
+316 -141
View File
@@ -1,9 +1,27 @@
import { useEffect, useRef, useState } from 'react';
import { Edit3, Eye, Plus, Search, Trash2 } from 'lucide-react';
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type TenantOption } from '@/api/adminApi';
import {
adminApi,
type ClientSmsApplication,
type ClientSmsSignature,
type ClientSmsTemplate,
type TenantOption,
} from '@/api/adminApi';
import {
Breadcrumb,
Button,
DeleteRiskAction,
Input,
Modal,
Pagination,
Select,
Tabs,
Tag,
Textarea,
} from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
import { Breadcrumb, Button, DeleteRiskAction, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
import { replaceLeadingSmsSignature } from '@/utils/smsSignature';
import { Edit3, Eye, Plus, Search } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { TemplateOptOutModal } from './TemplateOptOutModal';
type TemplateFormState = {
tenantId: string;
@@ -89,7 +107,7 @@ function TemplateFormModal({
const initialSignature = signatures.find((signature) => signature.id === item?.signatureId);
const initialContent = item?.signatureId
? replaceLeadingSmsSignature(item.content, initialSignature?.name)
: item?.content ?? '';
: (item?.content ?? '');
const [form, setForm] = useState<TemplateFormState>({
tenantId: item?.tenantId ?? '',
applicationId: item?.applicationId ?? '',
@@ -97,16 +115,24 @@ function TemplateFormModal({
name: item?.name ?? '',
content: initialContent,
category: item?.category ?? '行业通知',
variables: item?.variables?.map((variable) => ({ name: variable.name, example: variable.example ?? undefined, required: variable.required ?? true })) ?? [],
variables:
item?.variables?.map((variable) => ({
name: variable.name,
example: variable.example ?? undefined,
required: variable.required ?? true,
})) ?? [],
});
const initialForm = useRef(form).current;
const [initialForm] = useState(form);
const dirty = JSON.stringify(form) !== JSON.stringify(initialForm);
const tenantApplications = applications.filter((application) => application.tenantId === form.tenantId && application.status !== 'deleted');
const tenantSignatures = signatures.filter((signature) => (
signature.tenantId === form.tenantId
&& signature.auditStatus !== 'deleted'
&& (!signature.applicationId || signature.applicationId === form.applicationId)
));
const tenantApplications = applications.filter(
(application) => application.tenantId === form.tenantId && application.status !== '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]) {
@@ -142,7 +168,9 @@ function TemplateFormModal({
}
function updateVariableExample(name: string, example: string) {
const variables = currentVariables.map((variable) => variable.name === name ? { ...variable, example } : variable);
const variables = currentVariables.map((variable) =>
variable.name === name ? { ...variable, example } : variable,
);
update('variables', variables);
}
@@ -151,14 +179,26 @@ function TemplateFormModal({
dirty={dirty}
footer={({ requestClose }) => (
<>
<Button onClick={requestClose} variant="ghost"></Button>
<Button disabled={!form.tenantId || !form.applicationId || !form.signatureId || !form.name || !form.content.trim()} onClick={() => onSubmit({ ...form, variables: currentVariables })}></Button>
<Button onClick={requestClose} variant="ghost">
</Button>
<Button
disabled={!form.tenantId || !form.applicationId || !form.signatureId || !form.name || !form.content.trim()}
onClick={() => onSubmit({ ...form, variables: currentVariables })}
>
</Button>
</>
)}
onClose={onClose}
open
size="xl"
title={<div className="template-modal-title"><h2>{item ? '编辑短信模板' : '添加短信模板'}</h2><p></p></div>}
title={
<div className="template-modal-title">
<h2>{item ? '编辑短信模板' : '添加短信模板'}</h2>
<p></p>
</div>
}
>
<div className="template-form">
<Select
@@ -192,8 +232,19 @@ function TemplateFormModal({
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} />
<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="模板内容"
@@ -208,7 +259,9 @@ function TemplateFormModal({
<button onClick={() => setVariablesOpen((current) => !current)} type="button">
<Plus size={16} /> {variablesOpen ? '收起变量面板' : '插入变量'}
</button>
<span>{form.content.length} {billingUnits(form.content)} </span>
<span>
{form.content.length} {billingUnits(form.content)}
</span>
</div>
{variablesOpen ? (
<div className="template-variable-panel">
@@ -222,22 +275,37 @@ function TemplateFormModal({
</div>
<h3></h3>
<div className="template-custom-variable">
<Input onChange={(event) => setCustomVariable(event.target.value)} placeholder="英文字符或数字" value={customVariable} />
<Button onClick={() => { insertVariable(customVariable); setCustomVariable(''); }}></Button>
<Input
onChange={(event) => setCustomVariable(event.target.value)}
placeholder="英文字符或数字"
value={customVariable}
/>
<Button
onClick={() => {
insertVariable(customVariable);
setCustomVariable('');
}}
>
</Button>
</div>
</div>
) : null}
<div className="template-variable-panel">
<h3></h3>
{currentVariables.length ? currentVariables.map((variable) => (
<Input
key={variable.name}
label={`\${${variable.name}}`}
onChange={(event) => updateVariableExample(variable.name, event.target.value)}
placeholder="请输入变量示例值"
value={variable.example ?? ''}
/>
)) : <p className="muted"></p>}
{currentVariables.length ? (
currentVariables.map((variable) => (
<Input
key={variable.name}
label={`\${${variable.name}}`}
onChange={(event) => updateVariableExample(variable.name, event.target.value)}
placeholder="请输入变量示例值"
value={variable.example ?? ''}
/>
))
) : (
<p className="muted"></p>
)}
</div>
</div>
</Modal>
@@ -248,36 +316,37 @@ function TemplatePreviewModal({ item, onClose }: { item: ClientSmsTemplate; onCl
return (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open title="模板预览">
<div className="detail-grid">
<div><span></span><strong>{item.tenant?.name ?? item.tenantId}</strong></div>
<div><span></span><strong>{item.application?.name ?? item.applicationId}</strong></div>
<div><span></span><strong>{item.signature?.name ?? '-'}</strong></div>
<div><span></span><strong>{billingUnits(item.content)} </strong></div>
<div className="detail-grid__wide"><span></span><strong>{item.content}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{item.variables?.map((variable) => `\${${variable.name}}`).join('、') || '-'}</strong></div>
<div>
<span></span>
<strong>{item.tenant?.name ?? item.tenantId}</strong>
</div>
<div>
<span></span>
<strong>{item.application?.name ?? item.applicationId}</strong>
</div>
<div>
<span></span>
<strong>{item.signature?.name ?? '-'}</strong>
</div>
<div>
<span></span>
<strong>{billingUnits(item.content)} </strong>
</div>
<div className="detail-grid__wide">
<span></span>
<strong>{item.content}</strong>
</div>
<div className="detail-grid__wide">
<span></span>
<strong>{item.variables?.map((variable) => `\${${variable.name}}`).join('、') || '-'}</strong>
</div>
</div>
</Modal>
);
}
function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCancel: () => void; onConfirm: () => void }) {
return (
<Modal
footer={(
<>
<Button onClick={onCancel} variant="ghost"></Button>
<Button onClick={onConfirm} variant="danger"></Button>
</>
)}
onClose={onCancel}
open
title="删除确认"
>
<p className="admin-confirm-text">{message}</p>
</Modal>
);
}
export function AdminEnterpriseTemplatesPage() {
const [policyTemplate, setPolicyTemplate] = useState<ClientSmsTemplate | null>(null);
const [activeTab, setActiveTab] = useState<'sms' | 'mms'>('sms');
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
@@ -300,23 +369,37 @@ export function AdminEnterpriseTemplatesPage() {
const pageSize = 10;
async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, nameKeyword: appliedTemplateNameKeyword, contentKeyword: appliedTemplateContentKeyword }, targetPage = page) {
function loadData(
filters = {
enterpriseKeyword: appliedEnterpriseKeyword,
applicationKeyword: appliedApplicationKeyword,
nameKeyword: appliedTemplateNameKeyword,
contentKeyword: appliedTemplateContentKeyword,
},
targetPage = page,
) {
const sequence = ++listRequestSequence.current;
try {
const templateResult = await adminApi.listEnterpriseTemplatesPage({ ...filters, page: targetPage, pageSize });
if (sequence !== listRequestSequence.current) return;
setTemplates(templateResult.items);
setTotal(templateResult.total);
setError('');
} catch (failure) {
if (sequence !== listRequestSequence.current) return;
setError(failure instanceof Error ? failure.message : '企业模板加载失败');
}
return adminApi
.listEnterpriseTemplatesPage({ ...filters, page: targetPage, pageSize })
.then((templateResult) => {
if (sequence !== listRequestSequence.current) return;
setTemplates(templateResult.items);
setTotal(templateResult.total);
setError('');
})
.catch((failure) => {
if (sequence !== listRequestSequence.current) return;
setError(failure instanceof Error ? failure.message : '企业模板加载失败');
});
}
useEffect(() => {
let cancelled = false;
void Promise.all([adminApi.listTenantOptions(), adminApi.listEnterpriseApplicationOptions(), adminApi.listEnterpriseSignatureOptions()])
void Promise.all([
adminApi.listTenantOptions(),
adminApi.listEnterpriseApplicationOptions(),
adminApi.listEnterpriseSignatureOptions(),
])
.then(([tenantItems, applicationItems, signatureList]) => {
if (cancelled) return;
setTenants(tenantItems.filter((tenant) => tenant.status !== 'deleted'));
@@ -326,7 +409,9 @@ export function AdminEnterpriseTemplatesPage() {
.catch((failure: Error) => {
if (!cancelled) setError(failure.message || '企业模板选项加载失败');
});
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
@@ -370,42 +455,86 @@ export function AdminEnterpriseTemplatesPage() {
return (
<section className="page-stack admin-customer-split-page">
{policyTemplate ? (
<TemplateOptOutModal template={policyTemplate} onClose={() => setPolicyTemplate(null)} />
) : null}
<div className="page-heading">
<div>
<Breadcrumb items={['客户管理', '企业模板管理']} />
<h1></h1>
</div>
<Button icon={<Plus size={16} />} onClick={() => setTemplateModal(activeTab === 'sms' ? 'new' : null)}></Button>
<Button icon={<Plus size={16} />} onClick={() => setTemplateModal(activeTab === 'sms' ? 'new' : null)}>
</Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-split-filter">
<Input label="企业名称" onChange={(event) => setEnterpriseKeyword(event.target.value)} placeholder="请输入企业名称" prefix={<Search size={16} />} value={enterpriseKeyword} />
<Input label="企业应用" onChange={(event) => setApplicationKeyword(event.target.value)} placeholder="请输入企业应用名称" prefix={<Search size={16} />} value={applicationKeyword} />
<Input label="模板名称" onChange={(event) => setTemplateNameKeyword(event.target.value)} placeholder="请输入模板名称" prefix={<Search size={16} />} value={templateNameKeyword} />
<Input label="模板内容" onChange={(event) => setTemplateContentKeyword(event.target.value)} placeholder="请输入模板内容" prefix={<Search size={16} />} value={templateContentKeyword} />
<Input
label="企业名称"
onChange={(event) => setEnterpriseKeyword(event.target.value)}
placeholder="请输入企业名称"
prefix={<Search size={16} />}
value={enterpriseKeyword}
/>
<Input
label="企业应用"
onChange={(event) => setApplicationKeyword(event.target.value)}
placeholder="请输入企业应用名称"
prefix={<Search size={16} />}
value={applicationKeyword}
/>
<Input
label="模板名称"
onChange={(event) => setTemplateNameKeyword(event.target.value)}
placeholder="请输入模板名称"
prefix={<Search size={16} />}
value={templateNameKeyword}
/>
<Input
label="模板内容"
onChange={(event) => setTemplateContentKeyword(event.target.value)}
placeholder="请输入模板内容"
prefix={<Search size={16} />}
value={templateContentKeyword}
/>
<div className="admin-split-filter__actions">
<Button icon={<Search size={16} />} onClick={() => {
const filters = { enterpriseKeyword: enterpriseKeyword.trim(), applicationKeyword: applicationKeyword.trim(), nameKeyword: templateNameKeyword.trim(), contentKeyword: templateContentKeyword.trim() };
setAppliedEnterpriseKeyword(filters.enterpriseKeyword);
setAppliedApplicationKeyword(filters.applicationKeyword);
setAppliedTemplateNameKeyword(filters.nameKeyword);
setAppliedTemplateContentKeyword(filters.contentKeyword);
if (page !== 1) setPage(1);
else void loadData(filters, 1);
}}></Button>
<Button onClick={() => {
const filters = { enterpriseKeyword: '', applicationKeyword: '', nameKeyword: '', contentKeyword: '' };
setEnterpriseKeyword('');
setApplicationKeyword('');
setTemplateNameKeyword('');
setTemplateContentKeyword('');
setAppliedEnterpriseKeyword('');
setAppliedApplicationKeyword('');
setAppliedTemplateNameKeyword('');
setAppliedTemplateContentKeyword('');
if (page !== 1) setPage(1);
else void loadData(filters, 1);
}} variant="ghost"></Button>
<Button
icon={<Search size={16} />}
onClick={() => {
const filters = {
enterpriseKeyword: enterpriseKeyword.trim(),
applicationKeyword: applicationKeyword.trim(),
nameKeyword: templateNameKeyword.trim(),
contentKeyword: templateContentKeyword.trim(),
};
setAppliedEnterpriseKeyword(filters.enterpriseKeyword);
setAppliedApplicationKeyword(filters.applicationKeyword);
setAppliedTemplateNameKeyword(filters.nameKeyword);
setAppliedTemplateContentKeyword(filters.contentKeyword);
if (page !== 1) setPage(1);
else void loadData(filters, 1);
}}
>
</Button>
<Button
onClick={() => {
const filters = { enterpriseKeyword: '', applicationKeyword: '', nameKeyword: '', contentKeyword: '' };
setEnterpriseKeyword('');
setApplicationKeyword('');
setTemplateNameKeyword('');
setTemplateContentKeyword('');
setAppliedEnterpriseKeyword('');
setAppliedApplicationKeyword('');
setAppliedTemplateNameKeyword('');
setAppliedTemplateContentKeyword('');
if (page !== 1) setPage(1);
else void loadData(filters, 1);
}}
variant="ghost"
>
</Button>
</div>
</div>
<div className="surface section-stack">
@@ -413,51 +542,93 @@ export function AdminEnterpriseTemplatesPage() {
onChange={(value) => setActiveTab(value as 'sms' | 'mms')}
value={activeTab}
items={[
{ label: '短信模板', value: 'sms', content: (
<div className="admin-enterprise-template-list">
{visibleTemplates.map((template) => (
<article className="admin-enterprise-template-row" key={template.id}>
<div className="admin-enterprise-template-row__identity">
<div className="admin-enterprise-template-row__title">
<strong>{template.name}</strong>
<Tag tone={statusTone(template.auditStatus)}>{auditStatusLabel[template.auditStatus] ?? template.auditStatus}</Tag>
{
label: '短信模板',
value: 'sms',
content: (
<div className="admin-enterprise-template-list">
{visibleTemplates.map((template) => (
<article className="admin-enterprise-template-row" key={template.id}>
<div className="admin-enterprise-template-row__identity">
<div className="admin-enterprise-template-row__title">
<strong>{template.name}</strong>
<Tag tone={statusTone(template.auditStatus)}>
{auditStatusLabel[template.auditStatus] ?? template.auditStatus}
</Tag>
</div>
<dl>
<div>
<dt></dt>
<dd>{template.tenant?.name ?? template.tenantId}</dd>
</div>
<div>
<dt></dt>
<dd>{template.application?.name ?? template.applicationId}</dd>
</div>
<div>
<dt></dt>
<dd>{template.signature?.name ?? '未绑定'}</dd>
</div>
</dl>
</div>
<dl>
<div><dt></dt><dd>{template.tenant?.name ?? template.tenantId}</dd></div>
<div><dt></dt><dd>{template.application?.name ?? template.applicationId}</dd></div>
<div><dt></dt><dd>{template.signature?.name ?? '未绑定'}</dd></div>
</dl>
</div>
<div className="admin-enterprise-template-row__content">
<span></span>
<p>{template.content}</p>
<small>{template.variables?.length ?? 0} </small>
</div>
<div className="admin-enterprise-template-row__meta">
<span></span>
<strong>{formatDate(template.updatedAt)}</strong>
</div>
<div className="admin-enterprise-template-row__actions">
<Button icon={<Eye size={15} />} onClick={() => setTemplatePreview(template)} size="sm" variant="ghost"></Button>
<Button icon={<Edit3 size={15} />} onClick={() => setTemplateModal(template)} size="sm" variant="ghost"></Button>
<DeleteRiskAction onCompleted={() => void loadData()} portal="admin" targetId={template.id} targetType="template" />
</div>
</article>
))}
{filteredTemplates.length === 0 ? <div className="ui-table__empty"></div> : null}
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPageChange={setPage}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
previousDisabled={currentPage <= 1}
total={total}
totalPages={totalPages}
/>
</div>
) },
{ label: '彩信模板', pending: true, value: 'mms', content: <div className="ui-table__empty"></div> },
<div className="admin-enterprise-template-row__content">
<span></span>
<p>{template.content}</p>
<small>{template.variables?.length ?? 0} </small>
</div>
<div className="admin-enterprise-template-row__meta">
<span></span>
<strong>{formatDate(template.updatedAt)}</strong>
</div>
<div className="admin-enterprise-template-row__actions">
<Button
icon={<Eye size={15} />}
onClick={() => setTemplatePreview(template)}
size="sm"
variant="ghost"
>
</Button>
<Button
icon={<Edit3 size={15} />}
onClick={() => setTemplateModal(template)}
size="sm"
variant="ghost"
>
</Button>
<Button onClick={() => setPolicyTemplate(template)} size="sm" variant="ghost">
</Button>
<DeleteRiskAction
onCompleted={() => void loadData()}
portal="admin"
targetId={template.id}
targetType="template"
/>
</div>
</article>
))}
{filteredTemplates.length === 0 ? <div className="ui-table__empty"></div> : null}
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPageChange={setPage}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
previousDisabled={currentPage <= 1}
total={total}
totalPages={totalPages}
/>
</div>
),
},
{
label: '彩信模板',
pending: true,
value: 'mms',
content: <div className="ui-table__empty"></div>,
},
]}
/>
</div>
@@ -467,12 +638,16 @@ export function AdminEnterpriseTemplatesPage() {
applications={applications}
item={templateModal === 'new' ? undefined : templateModal}
onClose={() => setTemplateModal(null)}
onSubmit={(state) => { void saveTemplate(state); }}
onSubmit={(state) => {
void saveTemplate(state);
}}
signatures={signatureItems}
tenants={tenants}
/>
) : null}
{templatePreview ? <TemplatePreviewModal item={templatePreview} onClose={() => setTemplatePreview(null)} /> : null}
{templatePreview ? (
<TemplatePreviewModal item={templatePreview} onClose={() => setTemplatePreview(null)} />
) : null}
</section>
);
}
+41
View File
@@ -0,0 +1,41 @@
.quality-status-bar {
display: flex;
align-items: center;
gap: var(--space-2);
}
.quality-status-bar .quality-status-bar__track {
display: flex;
flex: 1;
min-width: 60px;
height: 8px;
overflow: hidden;
border-radius: 99px;
background: var(--color-surface-muted);
}
.quality-status-bar .quality-status-bar__segment {
flex-shrink: 0;
height: 100%;
}
.quality-status-bar .quality-status-bar__segment--0 {
background: var(--color-success);
}
.quality-status-bar .quality-status-bar__segment--1 {
background: var(--color-warning);
}
.quality-status-bar .quality-status-bar__segment--2 {
background: var(--color-danger);
}
.quality-status-bar .quality-status-bar__segment--3 {
background: var(--color-text-muted);
}
.quality-status-bar strong {
min-width: 50px;
text-align: right;
}
+33
View File
@@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { QualityStatusBar } from './QualityStatusBar';
describe('quality status composition', () => {
it('shows four segments in one bar and puts unknown only in tooltip', () => {
render(
<QualityStatusBar
metric={{ total: 100, successCount: 60, submitFailureCount: 10, failureCount: 20, unknownCount: 10 }}
/>,
);
const bar = screen.getByRole('img');
expect(bar.children).toHaveLength(4);
expect([...bar.children].map((el) => (el as HTMLElement).style.width)).toEqual(['60%', '10%', '20%', '10%']);
expect(bar.getAttribute('title')).toContain('未收到回执:10 条(10.0%');
expect(screen.queryByText(/未收到回执/)).not.toBeInTheDocument();
expect(screen.getByText('60.0%')).toBeInTheDocument();
});
it('handles no submissions and reports inconsistent counts without invented values', () => {
const view = render(
<QualityStatusBar
metric={{ total: 0, successCount: 0, submitFailureCount: 0, failureCount: 0, unknownCount: 0 }}
/>,
);
expect(screen.getByRole('img')).toHaveAttribute('title', '暂无提交');
view.rerender(
<QualityStatusBar
metric={{ total: 2, successCount: 2, submitFailureCount: 1, failureCount: 0, unknownCount: 0 }}
/>,
);
expect(screen.getByRole('img').children).toHaveLength(0);
expect(screen.getByText('—')).toBeInTheDocument();
});
});
+38
View File
@@ -0,0 +1,38 @@
import './QualityStatusBar.css';
type Counts = {
total: number;
successCount: number;
submitFailureCount: number;
failureCount: number;
unknownCount: number;
};
export function QualityStatusBar({ metric }: { metric: Counts }) {
const counts = [metric.successCount, metric.submitFailureCount, metric.failureCount, metric.unknownCount];
const labels = ['已到达', '提交失败', '回执失败', '未收到回执'];
const valid = counts.every((n) => Number.isFinite(n) && n >= 0) && counts.reduce((a, b) => a + b, 0) === metric.total;
const percentage = (count: number) => (metric.total > 0 ? (count / metric.total) * 100 : 0);
const title = !valid
? '统计数据不一致,请刷新后重试'
: metric.total === 0
? '暂无提交'
: counts
.map((count, i) => `${labels[i]}${count.toLocaleString('zh-CN')} 条(${percentage(count).toFixed(1)}%`)
.join('\n');
return (
<div className="quality-status-bar">
<div className="quality-status-bar__track" title={title} aria-label={title} role="img" tabIndex={0}>
{valid && metric.total > 0
? counts.map((count, i) => (
<span
key={labels[i]}
className={`quality-status-bar__segment quality-status-bar__segment--${i}`}
style={{ width: `${percentage(count)}%` }}
/>
))
: null}
</div>
<strong>{valid ? `${percentage(metric.successCount).toFixed(1)}%` : '—'}</strong>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
.template-optout {
display: grid;
gap: var(--space-4);
}
.template-optout .template-optout__preserve {
display: flex;
align-items: center;
gap: var(--space-2);
}
.template-optout .template-optout__row {
display: grid;
grid-template-columns: 1fr 240px;
align-items: center;
gap: var(--space-4);
padding-block: var(--space-3);
border-top: 1px solid var(--color-border);
}
.template-optout .template-optout__row small {
display: block;
color: var(--color-text-muted);
margin-top: var(--space-1);
overflow-wrap: anywhere;
}
@media (max-width: 600px) {
.template-optout .template-optout__row {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,46 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TemplateOptOutModal } from './TemplateOptOutModal';
const api = vi.hoisted(() => ({ getTemplateOptOutPolicy: vi.fn(), updateTemplateOptOutPolicy: vi.fn() }));
vi.mock('@/api/adminApi', () => ({ adminApi: api }));
const policy = {
rules: [{ channelId: 'a', action: 'add' }],
preserveFragments: true,
channels: [{ id: 'a', name: '通道甲', groupNames: ['移动组'] }],
};
describe('opt-out policy editor', () => {
beforeEach(() => {
vi.clearAllMocks();
api.getTemplateOptOutPolicy.mockResolvedValue(policy);
});
it('keeps failed saves visible and cannot disable fragment protection', async () => {
api.updateTemplateOptOutPolicy.mockRejectedValueOnce(new Error('保存失败')).mockResolvedValueOnce({});
const close = vi.fn();
render(<TemplateOptOutModal template={{ id: 't', name: '模板甲' }} onClose={close} />);
await screen.findByLabelText('通道甲的拒收指令');
expect(screen.getByLabelText('避免影响消息分片数')).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: '保存策略' }));
await screen.findByRole('alert');
expect(close).not.toHaveBeenCalled();
expect(api.updateTemplateOptOutPolicy).toHaveBeenCalledWith('t', { rules: policy.rules, preserveFragments: true });
fireEvent.click(screen.getByRole('button', { name: '保存策略' }));
await waitFor(() => expect(close).toHaveBeenCalledTimes(1));
});
it('does not allow saving an unloaded policy and supports retry', async () => {
api.getTemplateOptOutPolicy.mockRejectedValueOnce(new Error('加载失败'));
render(<TemplateOptOutModal template={{ id: 't', name: '模板甲' }} onClose={vi.fn()} />);
await screen.findByRole('alert');
expect(screen.getByRole('button', { name: '保存策略' })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: '重试' }));
await screen.findByLabelText('通道甲的拒收指令');
expect(screen.getByRole('button', { name: '保存策略' })).toBeEnabled();
});
it('requires removing stale channel rules explicitly', async () => {
api.getTemplateOptOutPolicy.mockResolvedValueOnce({ ...policy, channels: [] });
render(<TemplateOptOutModal template={{ id: 't', name: '模板甲' }} onClose={vi.fn()} />);
const remove = await screen.findByRole('button', { name: '移除失效规则' });
expect(screen.getByRole('button', { name: '保存策略' })).toBeDisabled();
fireEvent.click(remove);
expect(screen.getByRole('button', { name: '保存策略' })).toBeEnabled();
});
});
+146
View File
@@ -0,0 +1,146 @@
import { useEffect, useState } from 'react';
import { adminApi } from '@/api/adminApi';
import { Button, Modal, Select } from '@/components/ui';
import './TemplateOptOutModal.css';
type Rule = { channelId: string; action: 'add' | 'remove' };
type Policy = {
rules: Rule[];
preserveFragments: boolean;
channels: { id: string; name: string; groupNames: string[] }[];
};
export function TemplateOptOutModal({
template,
onClose,
}: {
template: { id: string; name: string };
onClose: () => void;
}) {
const [policy, setPolicy] = useState<Policy>();
const [rules, setRules] = useState<Rule[]>([]);
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(true);
const [reload, setReload] = useState(0);
useEffect(() => {
let active = true;
setLoading(true);
setError('');
adminApi
.getTemplateOptOutPolicy(template.id)
.then((value) => {
if (active) {
setPolicy(value);
setRules(value.rules);
}
})
.catch((e) => {
if (active) setError(e instanceof Error ? e.message : '策略加载失败');
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [template.id, reload]);
async function save() {
setSaving(true);
setError('');
try {
await adminApi.updateTemplateOptOutPolicy(template.id, { rules, preserveFragments: true });
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : '策略保存失败');
} finally {
setSaving(false);
}
}
const unavailable = rules.filter((rule) => !policy?.channels.some((c) => c.id === rule.channelId));
return (
<Modal
open
title="模板拒收指令"
size="xl"
onClose={onClose}
dirty={Boolean(policy && JSON.stringify(rules) !== JSON.stringify(policy.rules))}
footer={({ requestClose }) => (
<>
<Button variant="ghost" disabled={saving} onClick={requestClose}>
</Button>
<Button disabled={loading || saving || !policy || unavailable.length > 0} onClick={() => void save()}>
{saving ? '保存中…' : '保存策略'}
</Button>
</>
)}
>
<div className="template-optout">
<p>
<strong>{template.name}</strong>
</p>
<p className="muted">R</p>
<label className="template-optout__preserve">
<input type="checkbox" checked disabled />
</label>
<p className="muted">
</p>
{error ? (
<div role="alert" className="form-error">
{error}
{!policy ? (
<Button variant="ghost" onClick={() => setReload((n) => n + 1)}>
</Button>
) : null}
</div>
) : null}
{loading ? (
<p role="status"></p>
) : policy?.channels.length === 0 ? (
<p></p>
) : null}
{!loading
? policy?.channels.map((channel) => (
<div className="template-optout__row" key={channel.id}>
<div>
<strong>{channel.name}</strong>
<small>{channel.groupNames.join('、')}</small>
</div>
<Select
label={`${channel.name}的拒收指令`}
value={rules.find((r) => r.channelId === channel.id)?.action ?? 'none'}
disabled={saving}
options={[
{ value: 'none', label: '保持原文' },
{ value: 'remove', label: '末尾删除拒收指令' },
{ value: 'add', label: '末尾增加拒收指令' },
]}
onChange={(event) => {
const action = event.target.value;
setRules((current) => [
...current.filter((r) => r.channelId !== channel.id),
...(action === 'none' ? [] : [{ channelId: channel.id, action: action as Rule['action'] }]),
]);
}}
/>
</div>
))
: null}
{unavailable.length ? (
<div role="alert">
<Button
onClick={() => setRules((current) => current.filter((r) => !unavailable.includes(r)))}
variant="ghost"
>
</Button>
</div>
) : null}
</div>
</Modal>
);
}
@@ -478,6 +478,27 @@
}
@media (max-width: 780px) {
.sms-channel-filter-grid,
.sms-channel-form-grid,
.sms-channel-inline-field {
grid-template-columns: minmax(0, 1fr);
}
.sms-channel-form-grid > .ui-field,
.sms-channel-radio-row,
.sms-channel-inline-field {
grid-column: 1;
}
.sms-channel-radio-row {
flex-wrap: wrap;
gap: var(--space-4);
}
.sms-channel-radio-row > span {
flex-basis: 100%;
}
.channel-connection-summary article {
grid-template-columns: 1fr;
}
@@ -156,6 +156,24 @@ export function SendDetailModal({ record, segmentAudits, segmentLoading, onClose
<p className="admin-sms-detail-content">
<DrainageContent record={record} />
</p>
{record.originalContent != null ? (
<>
<h3></h3>
<p className="admin-sms-detail-content">{record.originalContent}</p>
</>
) : null}
{record.originalContent != null
? record.submitRecords
?.filter((submit) => submit.sentContent != null)
.map((submit, index) => (
<div key={submit.id}>
<h3>
{index + 1} · {submit.channel?.name ?? submit.channelId}
</h3>
<p className="admin-sms-detail-content">{submit.sentContent}</p>
</div>
))
: null}
</section>
<section>
+6
View File
@@ -291,6 +291,12 @@ export function ClientSendDetailPage() {
<div className="send-detail-content-block">
<span></span>
<p>{record.content}</p>
{record.originalContent != null ? (
<>
<span></span>
<p>{record.originalContent}</p>
</>
) : null}
</div>
</td>
</tr>