feat: 增加模板通道拒收策略并修复运营页面
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user