Files
lislgosms/src/apps/client/ClientTemplatesPage.tsx
T

352 lines
14 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
import { Edit3, MessageSquare, Plus, Search, Trash2 } from 'lucide-react';
import { Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
import { replaceLeadingSmsSignature } from '@/utils/smsSignature';
type TemplateVariable = {
name: string;
example?: string;
required?: boolean;
};
type TemplateFormState = {
applicationId: string;
signatureId: string;
name: string;
category: string;
content: string;
variables: TemplateVariable[];
};
const statusTone: Record<string, 'success' | 'info' | 'danger' | 'warning'> = {
approved: 'success',
pending: 'info',
rejected: 'danger',
draft: 'warning',
};
const statusLabel: Record<string, string> = {
approved: '已通过',
pending: '审核中',
rejected: '已驳回',
draft: '草稿',
disabled: '已禁用',
};
const recommendedVariables = [
['验证码', 'code'],
['手机号', 'phone'],
['姓名', 'name'],
['日期', 'date'],
['金额', 'amount'],
['时间', 'time'],
['余额', 'balance'],
['地址', 'address'],
['快递单号', 'trackingNumber'],
['链接', 'link'],
];
function extractVariables(content: string): TemplateVariable[] {
return Array.from(new Set(Array.from(content.matchAll(/\$\{([^}]+)\}/g)).map((match) => match[1])))
.map((name) => ({ name, required: true }));
}
function billingUnits(content: string) {
if (!content) return 1;
return content.length <= 70 ? 1 : Math.ceil(content.length / 67);
}
function TemplateModal({
applications,
item,
onClose,
onSubmit,
signatures,
}: {
applications: ClientSmsApplication[];
item?: ClientSmsTemplate;
onClose: () => void;
onSubmit: (state: TemplateFormState) => void;
signatures: ClientSmsSignature[];
}) {
const [customVariable, setCustomVariable] = useState('');
const [variablesOpen, setVariablesOpen] = useState(false);
const contentRef = useRef<HTMLTextAreaElement>(null);
const initialSignature = signatures.find((signature) => signature.id === item?.signatureId);
const initialContent = item?.signatureId
? replaceLeadingSmsSignature(item.content, initialSignature?.name)
: item?.content ?? '';
const [form, setForm] = useState<TemplateFormState>({
applicationId: item?.applicationId ?? '',
signatureId: item?.signatureId ?? '',
name: item?.name ?? '',
category: item?.category ?? '行业通知',
content: initialContent,
variables: item?.variables?.map((variable) => ({ name: variable.name, example: variable.example ?? undefined, required: variable.required ?? true })) ?? [],
});
const application = applications.find((candidate) => candidate.id === form.applicationId);
const availableSignatures = signatures.filter((signature) => (
signature.auditStatus === 'approved'
&& (!application || signature.tenantId === application.tenantId)
&& (!signature.applicationId || signature.applicationId === form.applicationId)
));
const variables = 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 = availableSignatures.find((candidate) => candidate.id === signatureId);
setForm((current) => {
const content = replaceLeadingSmsSignature(current.content, signature?.name);
return { ...current, signatureId, content, variables: extractVariables(content) };
});
}
function insertVariable(name: string) {
const normalized = name.trim();
if (!normalized) return;
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) {
update('variables', variables.map((variable) => variable.name === name ? { ...variable, example } : variable));
}
return (
<Modal
footer={(
<>
<Button onClick={onClose} variant="ghost">取消</Button>
<Button disabled={!form.applicationId || !form.signatureId || !form.name || !form.content.trim()} onClick={() => onSubmit({ ...form, variables })}>提交审核</Button>
</>
)}
onClose={onClose}
open
size="xl"
title={item ? '编辑短信模板' : '添加短信模板'}
>
<div className="template-form">
<Select
label="短信应用"
onChange={(event) => update('applicationId', event.target.value)}
options={[{ label: '请选择应用', value: '' }, ...applications.map((app) => ({ label: app.name, value: app.id }))]}
value={form.applicationId}
/>
<Select
label="短信签名"
onChange={(event) => selectSignature(event.target.value)}
options={[{ label: '请选择签名', value: '' }, ...availableSignatures.map((signature) => ({ label: signature.name, value: signature.id }))]}
required
value={form.signatureId}
/>
<Input label="模板名称" onChange={(event) => update('name', event.target.value)} placeholder="请输入模板名称" value={form.name} />
<Input label="模板分类" onChange={(event) => update('category', event.target.value)} placeholder="行业通知/营销推广/验证码" value={form.category} />
<Textarea
hint="模板内容必须以所选签名开头;选择或切换签名时系统会自动填入或替换完整签名,例如:【XX公司】验证码为${code}。"
label="模板内容"
onChange={(event) => setContent(event.target.value)}
placeholder="请选择签名后填写正文,变量格式:${code}"
ref={contentRef}
required
rows={6}
value={form.content}
/>
<div className="template-form-meta">
<button onClick={() => setVariablesOpen((current) => !current)} type="button">
<Plus size={16} /> {variablesOpen ? '收起变量面板' : '插入变量'}
</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>
{variables.length ? variables.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>
);
}
export function ClientTemplatesPage() {
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
const [templates, setTemplates] = useState<ClientSmsTemplate[]>([]);
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
const [keyword, setKeyword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [modalTemplate, setModalTemplate] = useState<ClientSmsTemplate | 'new' | null>(null);
const [page, setPage] = useState(1);
function loadData() {
setLoading(true);
Promise.all([clientApi.listApplications(), clientApi.listTemplates(), clientApi.listSignatures()])
.then(([applicationItems, templateItems, signatureItems]) => {
setApplications(applicationItems.filter((item) => item.status === 'active'));
setTemplates(templateItems.filter((item) => item.auditStatus !== 'deleted' && item.auditStatus !== 'disabled'));
setSignatures(signatureItems);
setError('');
})
.catch((reason: Error) => setError(reason.message || '短信模板加载失败'))
.finally(() => setLoading(false));
}
useEffect(() => {
loadData();
}, []);
const filteredTemplates = useMemo(() => templates.filter((item) => (
!keyword || [item.name, item.content, item.application?.name, item.signature?.name].join(' ').includes(keyword)
)), [keyword, 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);
}, [filteredTemplates.length, keyword]);
async function saveTemplate(state: TemplateFormState) {
const existing = modalTemplate && modalTemplate !== 'new' ? modalTemplate : null;
try {
const payload = {
applicationId: state.applicationId,
signatureId: state.signatureId || undefined,
name: state.name,
content: state.content,
category: state.category,
variables: state.variables,
};
const template = existing
? await clientApi.updateTemplate(existing.id, { ...payload, signatureId: state.signatureId || null })
: await clientApi.createTemplate(payload);
await clientApi.submitTemplate(template.id);
setModalTemplate(null);
loadData();
} catch (reason) {
setError(reason instanceof Error ? reason.message : '模板提交失败');
}
}
function disableTemplate(id: string) {
clientApi.changeTemplateStatus(id, 'disabled')
.then(loadData)
.catch((reason: Error) => setError(reason.message || '模板禁用失败'));
}
return (
<section className="page-stack">
<div className="template-page-header">
<div className="sms-send-title">
<span className="sms-send-title__icon">
<MessageSquare size={22} />
</span>
<h1>短信模板列表</h1>
</div>
</div>
<div className="template-toolbar">
<Input
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索模板名称、应用、签名或内容"
prefix={<Search size={17} />}
value={keyword}
/>
<Button icon={<Plus size={17} />} onClick={() => setModalTemplate('new')}>添加短信模板</Button>
</div>
{loading ? <p className="muted">正在加载短信模板...</p> : null}
{error ? <p className="form-error">{error}</p> : null}
<div className="template-card-grid">
{visibleTemplates.map((template) => {
const variables = template.variables?.map((item) => item.name) ?? extractVariables(template.content).map((item) => item.name);
return (
<article className="template-card template-card--green" key={template.id}>
<h2>{template.name}</h2>
<p className="muted">{template.application?.name ?? template.applicationId} / {template.signature?.name ?? '未绑定签名'}</p>
<Tag tone={statusTone[template.auditStatus] ?? 'info'}>{statusLabel[template.auditStatus] ?? template.auditStatus}</Tag>
<p className="template-content">{template.content}</p>
<div className="template-vars">
<span>变量:</span>
{variables.length > 0 ? variables.map((item) => <strong key={item}>${`{${item}}`}</strong>) : <span className="muted">无变量</span>}
</div>
<div className="template-card-footer">
<span>{formatDateTime(template.updatedAt)}</span>
<div>
<button onClick={() => setModalTemplate(template)} type="button">
<Edit3 size={14} />
编辑
</button>
<button onClick={() => disableTemplate(template.id)} type="button">
<Trash2 size={14} />
删除
</button>
</div>
</div>
</article>
);
})}
</div>
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage((value) => Math.min(totalPages, value + 1))}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={currentPage}
totalPages={totalPages}
onPageChange={setPage}
previousDisabled={currentPage <= 1}
total={filteredTemplates.length}
/>
{!loading && !error && filteredTemplates.length === 0 ? <p className="muted">暂无短信模板。</p> : null}
{modalTemplate ? (
<TemplateModal
applications={applications}
item={modalTemplate === 'new' ? undefined : modalTemplate}
onClose={() => setModalTemplate(null)}
onSubmit={(state) => { void saveTemplate(state); }}
signatures={signatures}
/>
) : null}
</section>
);
}