488 lines
22 KiB
TypeScript
488 lines
22 KiB
TypeScript
import { useEffect, useMemo, 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 { 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;
|
||
applicationId: string;
|
||
signatureId: string;
|
||
name: string;
|
||
content: string;
|
||
category: string;
|
||
variables: TemplateVariable[];
|
||
};
|
||
|
||
type TemplateVariable = {
|
||
name: string;
|
||
example?: string;
|
||
required?: boolean;
|
||
};
|
||
|
||
const recommendedVariables = [
|
||
['验证码', 'code'],
|
||
['手机号', 'phone'],
|
||
['姓名', 'name'],
|
||
['日期', 'date'],
|
||
['金额', 'amount'],
|
||
['时间', 'time'],
|
||
['余额', 'balance'],
|
||
['地址', 'address'],
|
||
['天数', 'days'],
|
||
['快递单号', 'trackingNumber'],
|
||
['案件号', 'caseNumber'],
|
||
['链接', 'link'],
|
||
['站点', 'station'],
|
||
];
|
||
|
||
function extractVariables(content: string): TemplateVariable[] {
|
||
const matches = content.match(/\$\{[a-zA-Z0-9_]+\}/g) ?? [];
|
||
return [...new Set(matches)].map((match) => ({ name: match.slice(2, -1), required: true }));
|
||
}
|
||
|
||
function formatDate(value?: string) {
|
||
return formatDateTime(value);
|
||
}
|
||
|
||
function statusTone(status: string) {
|
||
if (status === 'approved') return 'success';
|
||
if (status === 'rejected') return 'danger';
|
||
if (status === 'deleted') return 'neutral';
|
||
return 'info';
|
||
}
|
||
|
||
const auditStatusLabel: Record<string, string> = {
|
||
approved: '已通过',
|
||
draft: '草稿',
|
||
pending: '审核中',
|
||
rejected: '已驳回',
|
||
deleted: '已删除',
|
||
};
|
||
|
||
function billingUnits(content: string) {
|
||
if (!content) {
|
||
return 1;
|
||
}
|
||
return content.length <= 70 ? 1 : Math.ceil(content.length / 67);
|
||
}
|
||
|
||
function TemplateFormModal({
|
||
applications,
|
||
item,
|
||
onClose,
|
||
onSubmit,
|
||
signatures,
|
||
tenants,
|
||
}: {
|
||
applications: ClientSmsApplication[];
|
||
item?: ClientSmsTemplate;
|
||
onClose: () => void;
|
||
onSubmit: (state: TemplateFormState) => void;
|
||
signatures: ClientSmsSignature[];
|
||
tenants: TenantOption[];
|
||
}) {
|
||
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: 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'
|
||
&& (!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]) {
|
||
setForm((current) => ({ ...current, [key]: value }));
|
||
}
|
||
|
||
function setContent(content: string) {
|
||
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) {
|
||
return;
|
||
}
|
||
const token = `\${${normalized}}`;
|
||
const textarea = contentRef.current;
|
||
const start = textarea?.selectionStart ?? form.content.length;
|
||
const end = textarea?.selectionEnd ?? start;
|
||
setContent(`${form.content.slice(0, start)}${token}${form.content.slice(end)}`);
|
||
requestAnimationFrame(() => {
|
||
contentRef.current?.focus();
|
||
contentRef.current?.setSelectionRange(start + token.length, start + token.length);
|
||
});
|
||
}
|
||
|
||
function updateVariableExample(name: string, example: string) {
|
||
const variables = currentVariables.map((variable) => variable.name === name ? { ...variable, example } : variable);
|
||
update('variables', variables);
|
||
}
|
||
|
||
return (
|
||
<Modal
|
||
footer={(
|
||
<>
|
||
<Button onClick={onClose} 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>}
|
||
>
|
||
<div className="template-form">
|
||
<Select
|
||
disabled={Boolean(item)}
|
||
label="所属企业"
|
||
onChange={(event) => update('tenantId', event.target.value)}
|
||
options={[
|
||
{ label: '请选择企业', value: '' },
|
||
...tenants.map((tenant) => ({ label: `${tenant.name}(${tenant.code})`, value: tenant.id })),
|
||
]}
|
||
required
|
||
value={form.tenantId}
|
||
/>
|
||
<Select
|
||
label="所属应用"
|
||
onChange={(event) => update('applicationId', event.target.value)}
|
||
options={[
|
||
{ label: '请选择应用', value: '' },
|
||
...tenantApplications.map((application) => ({ label: application.name, value: application.id })),
|
||
]}
|
||
required
|
||
value={form.applicationId}
|
||
/>
|
||
<Select
|
||
label="签名"
|
||
onChange={(event) => selectSignature(event.target.value)}
|
||
options={[
|
||
{ 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}。"
|
||
required
|
||
rows={8}
|
||
ref={contentRef}
|
||
value={form.content}
|
||
/>
|
||
<div className="template-form-meta">
|
||
<button onClick={() => setVariablesOpen((current) => !current)} type="button">
|
||
<Plus size={16} /> {variablesOpen ? '收起变量面板' : '插入变量'}
|
||
</button>
|
||
<span>{form.content.length} 字符,计费 {billingUnits(form.content)} 条</span>
|
||
</div>
|
||
{variablesOpen ? (
|
||
<div className="template-variable-panel">
|
||
<h3>推荐变量</h3>
|
||
<div className="template-variable-buttons">
|
||
{recommendedVariables.map(([label, value]) => (
|
||
<button key={value} onClick={() => insertVariable(value)} type="button">
|
||
{label} ({value})
|
||
</button>
|
||
))}
|
||
</div>
|
||
<h3>自定义变量</h3>
|
||
<div className="template-custom-variable">
|
||
<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>}
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
function TemplatePreviewModal({ item, onClose }: { item: ClientSmsTemplate; onClose: () => void }) {
|
||
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>
|
||
</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 [activeTab, setActiveTab] = useState<'sms' | 'mms'>('sms');
|
||
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
||
const [deleteTarget, setDeleteTarget] = useState<ClientSmsTemplate | null>(null);
|
||
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
||
const [appliedEnterpriseKeyword, setAppliedEnterpriseKeyword] = useState('');
|
||
const [applicationKeyword, setApplicationKeyword] = useState('');
|
||
const [appliedApplicationKeyword, setAppliedApplicationKeyword] = useState('');
|
||
const [error, setError] = useState('');
|
||
const [signatureItems, setSignatureItems] = useState<ClientSmsSignature[]>([]);
|
||
const [templateModal, setTemplateModal] = useState<ClientSmsTemplate | 'new' | null>(null);
|
||
const [templatePreview, setTemplatePreview] = useState<ClientSmsTemplate | null>(null);
|
||
const [templates, setTemplates] = useState<ClientSmsTemplate[]>([]);
|
||
const [templateNameKeyword, setTemplateNameKeyword] = useState('');
|
||
const [appliedTemplateNameKeyword, setAppliedTemplateNameKeyword] = useState('');
|
||
const [templateContentKeyword, setTemplateContentKeyword] = useState('');
|
||
const [appliedTemplateContentKeyword, setAppliedTemplateContentKeyword] = useState('');
|
||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||
const [page, setPage] = useState(1);
|
||
|
||
async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, nameKeyword: appliedTemplateNameKeyword, contentKeyword: appliedTemplateContentKeyword }) {
|
||
try {
|
||
const [templateItems, tenantItems, applicationItems, signatureList] = await Promise.all([
|
||
adminApi.listEnterpriseTemplates(filters),
|
||
adminApi.listTenants(),
|
||
adminApi.listEnterpriseApplications(),
|
||
adminApi.listEnterpriseSignatures(),
|
||
]);
|
||
setTemplates(templateItems);
|
||
setTenants(tenantItems.filter((tenant) => tenant.status !== 'deleted'));
|
||
setApplications(applicationItems);
|
||
setSignatureItems(signatureList);
|
||
setError('');
|
||
} catch (failure) {
|
||
setError(failure instanceof Error ? failure.message : '企业模板加载失败');
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
void loadData();
|
||
}, []);
|
||
|
||
const filteredTemplates = useMemo(() => templates.filter((item) => {
|
||
const enterprise = item.tenant?.name ?? item.tenantId;
|
||
const application = item.application?.name ?? '';
|
||
return (!appliedEnterpriseKeyword || enterprise.includes(appliedEnterpriseKeyword))
|
||
&& (!appliedApplicationKeyword || application.includes(appliedApplicationKeyword))
|
||
&& (!appliedTemplateNameKeyword || item.name.includes(appliedTemplateNameKeyword))
|
||
&& (!appliedTemplateContentKeyword || item.content.includes(appliedTemplateContentKeyword));
|
||
}), [appliedApplicationKeyword, appliedEnterpriseKeyword, appliedTemplateContentKeyword, appliedTemplateNameKeyword, templates]);
|
||
const pageSize = 10;
|
||
const totalPages = Math.max(1, Math.ceil(filteredTemplates.length / pageSize));
|
||
const currentPage = Math.min(page, totalPages);
|
||
const visibleTemplates = filteredTemplates.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||
|
||
useEffect(() => {
|
||
setPage(1);
|
||
}, [appliedApplicationKeyword, appliedEnterpriseKeyword, appliedTemplateContentKeyword, appliedTemplateNameKeyword]);
|
||
|
||
async function saveTemplate(state: TemplateFormState) {
|
||
const existing = templateModal && templateModal !== 'new' ? templateModal : null;
|
||
try {
|
||
if (existing) {
|
||
await adminApi.updateEnterpriseTemplate(existing.id, {
|
||
applicationId: state.applicationId,
|
||
category: state.category,
|
||
content: state.content,
|
||
name: state.name,
|
||
signatureId: state.signatureId || null,
|
||
variables: state.variables,
|
||
});
|
||
} else {
|
||
await adminApi.createEnterpriseTemplate({
|
||
applicationId: state.applicationId,
|
||
category: state.category,
|
||
content: state.content,
|
||
name: state.name,
|
||
signatureId: state.signatureId || undefined,
|
||
tenantId: state.tenantId,
|
||
variables: state.variables,
|
||
});
|
||
}
|
||
setTemplateModal(null);
|
||
await loadData();
|
||
} catch (failure) {
|
||
setError(failure instanceof Error ? failure.message : '企业模板保存失败');
|
||
}
|
||
}
|
||
|
||
async function confirmDelete() {
|
||
if (!deleteTarget) {
|
||
return;
|
||
}
|
||
await adminApi.changeEnterpriseTemplateStatus(deleteTarget.id, 'deleted', '运营端删除模板');
|
||
setDeleteTarget(null);
|
||
await loadData();
|
||
}
|
||
|
||
return (
|
||
<section className="page-stack admin-customer-split-page">
|
||
<div className="page-heading">
|
||
<div>
|
||
<Breadcrumb items={['客户管理', '企业模板管理']} />
|
||
<h1>企业模板管理</h1>
|
||
</div>
|
||
<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} />
|
||
<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);
|
||
void loadData(filters);
|
||
}}>查询</Button>
|
||
<Button onClick={() => {
|
||
const filters = { enterpriseKeyword: '', applicationKeyword: '', nameKeyword: '', contentKeyword: '' };
|
||
setEnterpriseKeyword('');
|
||
setApplicationKeyword('');
|
||
setTemplateNameKeyword('');
|
||
setTemplateContentKeyword('');
|
||
setAppliedEnterpriseKeyword('');
|
||
setAppliedApplicationKeyword('');
|
||
setAppliedTemplateNameKeyword('');
|
||
setAppliedTemplateContentKeyword('');
|
||
void loadData(filters);
|
||
}} variant="ghost">重置</Button>
|
||
</div>
|
||
</div>
|
||
<div className="surface section-stack">
|
||
<Tabs
|
||
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>
|
||
</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>
|
||
<Button icon={<Trash2 size={15} />} onClick={() => setDeleteTarget(template)} size="sm" variant="danger">删除</Button>
|
||
</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={filteredTemplates.length}
|
||
totalPages={totalPages}
|
||
/>
|
||
</div>
|
||
) },
|
||
{ label: '彩信模板', pending: true, value: 'mms', content: <div className="ui-table__empty">彩信模板待后端能力确认,本页不展示演示数据。</div> },
|
||
]}
|
||
/>
|
||
</div>
|
||
|
||
{templateModal ? (
|
||
<TemplateFormModal
|
||
applications={applications}
|
||
item={templateModal === 'new' ? undefined : templateModal}
|
||
onClose={() => setTemplateModal(null)}
|
||
onSubmit={(state) => { void saveTemplate(state); }}
|
||
signatures={signatureItems}
|
||
tenants={tenants}
|
||
/>
|
||
) : null}
|
||
{templatePreview ? <TemplatePreviewModal item={templatePreview} onClose={() => setTemplatePreview(null)} /> : null}
|
||
{deleteTarget ? (
|
||
<ConfirmModal
|
||
message={`确认删除模板“${deleteTarget.name}”吗?删除后会写入真实后台。`}
|
||
onCancel={() => setDeleteTarget(null)}
|
||
onConfirm={() => { void confirmDelete(); }}
|
||
/>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|