Files
lislgosms/src/apps/admin/AdminEnterpriseTemplatesPage.tsx
T

468 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { formatDateTime } from '@/utils/dateTime';
import { Breadcrumb, Button, DeleteRiskAction, 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 initialForm = useRef(form).current;
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 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
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>
</>
)}
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 [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 [total, setTotal] = useState(0);
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);
const pageSize = 10;
async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, nameKeyword: appliedTemplateNameKeyword, contentKeyword: appliedTemplateContentKeyword }, targetPage = page) {
try {
const [templateResult, tenantItems, applicationItems, signatureList] = await Promise.all([
adminApi.listEnterpriseTemplatesPage({ ...filters, page: targetPage, pageSize }),
adminApi.listTenants(),
adminApi.listEnterpriseApplicationOptions(),
adminApi.listEnterpriseSignatureOptions(),
]);
setTemplates(templateResult.items);
setTotal(templateResult.total);
setTenants(tenantItems.filter((tenant) => tenant.status !== 'deleted'));
setApplications(applicationItems);
setSignatureItems(signatureList);
setError('');
} catch (failure) {
setError(failure instanceof Error ? failure.message : '企业模板加载失败');
}
}
useEffect(() => {
void loadData(undefined, page);
}, [page]);
const filteredTemplates = templates;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const currentPage = Math.min(page, totalPages);
const visibleTemplates = templates;
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 : '企业模板保存失败');
}
}
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);
setPage(1);
void loadData(filters, 1);
}}>查询</Button>
<Button onClick={() => {
const filters = { enterpriseKeyword: '', applicationKeyword: '', nameKeyword: '', contentKeyword: '' };
setEnterpriseKeyword('');
setApplicationKeyword('');
setTemplateNameKeyword('');
setTemplateContentKeyword('');
setAppliedEnterpriseKeyword('');
setAppliedApplicationKeyword('');
setAppliedTemplateNameKeyword('');
setAppliedTemplateContentKeyword('');
setPage(1);
void loadData(filters, 1);
}} 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>
<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>
{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}
</section>
);
}