494 lines
17 KiB
TypeScript
494 lines
17 KiB
TypeScript
import { startTransition, useEffect, useRef, useState } from 'react';
|
|
import { Edit3, MessageSquare, Plus, Search } from 'lucide-react';
|
|
import { Button, DeleteRiskAction, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
|
import {
|
|
clientApi,
|
|
type ClientSmsApplication,
|
|
type ClientSmsSignatureView,
|
|
type ClientSmsTemplate,
|
|
} from '@/api/adminApi';
|
|
import { formatDateTime } from '@/utils/dateTime';
|
|
import { replaceLeadingSmsSignature } from '@/utils/smsSignature';
|
|
import {
|
|
templateVariableError,
|
|
templateVariableNameHint,
|
|
templateVariableNamePattern,
|
|
} from '@/utils/templateVariables';
|
|
import './ClientTemplatesPage.css';
|
|
|
|
type TemplateVariable = {
|
|
name: string;
|
|
example?: string;
|
|
required?: boolean;
|
|
};
|
|
|
|
type TemplateFormState = {
|
|
applicationId: string;
|
|
signatureId: string;
|
|
name: 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: ClientSmsSignatureView[];
|
|
}) {
|
|
const [customVariable, setCustomVariable] = useState('');
|
|
const [customVariableError, setCustomVariableError] = 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 ?? '',
|
|
content: initialContent,
|
|
variables:
|
|
item?.variables?.map((variable) => ({
|
|
name: variable.name,
|
|
example: variable.example ?? undefined,
|
|
required: variable.required ?? true,
|
|
})) ?? [],
|
|
});
|
|
const [initialForm] = useState(form);
|
|
const dirty = JSON.stringify(form) !== JSON.stringify(initialForm);
|
|
const variableError = templateVariableError(form.content);
|
|
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;
|
|
if (!templateVariableNamePattern.test(normalized)) {
|
|
setCustomVariableError(templateVariableNameHint);
|
|
return;
|
|
}
|
|
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
|
|
dirty={dirty}
|
|
footer={({ requestClose }) => (
|
|
<>
|
|
<Button onClick={requestClose} variant="ghost">
|
|
取消
|
|
</Button>
|
|
<Button
|
|
disabled={
|
|
!form.applicationId ||
|
|
!form.signatureId ||
|
|
!form.name.trim() ||
|
|
!form.content.trim() ||
|
|
Boolean(variableError)
|
|
}
|
|
onClick={() => onSubmit({ ...form, variables })}
|
|
>
|
|
提交审核
|
|
</Button>
|
|
</>
|
|
)}
|
|
onClose={onClose}
|
|
open
|
|
size="xl"
|
|
title={item ? '编辑短信模板' : '添加短信模板'}
|
|
>
|
|
<div className="template-form client-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}
|
|
/>
|
|
<Input
|
|
label="模板名称"
|
|
onChange={(event) => update('name', event.target.value)}
|
|
placeholder="请输入模板名称"
|
|
value={form.name}
|
|
/>
|
|
<Select
|
|
label="短信签名"
|
|
searchable
|
|
searchPlaceholder="搜索短信签名"
|
|
onChange={(event) => selectSignature(event.target.value)}
|
|
options={[
|
|
{ label: '请选择签名', value: '' },
|
|
...availableSignatures.map((signature) => ({ label: signature.name, value: signature.id })),
|
|
]}
|
|
required
|
|
value={form.signatureId}
|
|
/>
|
|
<Textarea
|
|
hint="模板内容必须以所选签名开头;选择或切换签名时系统会自动填入或替换完整签名,例如:【XX公司】验证码为${code}。"
|
|
error={variableError}
|
|
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
|
|
aria-label="自定义变量名"
|
|
error={customVariableError}
|
|
maxLength={32}
|
|
onChange={(event) => {
|
|
if (/[^A-Za-z0-9]/.test(event.target.value)) {
|
|
setCustomVariableError(templateVariableNameHint);
|
|
return;
|
|
}
|
|
setCustomVariableError('');
|
|
setCustomVariable(event.target.value);
|
|
}}
|
|
placeholder="英文字符或数字"
|
|
value={customVariable}
|
|
/>
|
|
<Button
|
|
onClick={() => {
|
|
if (!templateVariableNamePattern.test(customVariable)) {
|
|
setCustomVariableError(templateVariableNameHint);
|
|
return;
|
|
}
|
|
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 requestSequence = useRef(0);
|
|
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
|
const [templates, setTemplates] = useState<ClientSmsTemplate[]>([]);
|
|
const [signatures, setSignatures] = useState<ClientSmsSignatureView[]>([]);
|
|
const [keyword, setKeyword] = useState('');
|
|
const [appliedKeyword, setAppliedKeyword] = useState('');
|
|
const [error, setError] = useState('');
|
|
const [loading, setLoading] = useState(true);
|
|
const [modalTemplate, setModalTemplate] = useState<ClientSmsTemplate | 'new' | null>(null);
|
|
const [page, setPage] = useState(1);
|
|
const [total, setTotal] = useState(0);
|
|
const pageSize = 10;
|
|
|
|
function loadData(targetPage = page) {
|
|
const sequence = ++requestSequence.current;
|
|
setLoading(true);
|
|
clientApi
|
|
.listTemplatesPage({ includeHistory: true, keyword: appliedKeyword || undefined, page: targetPage, pageSize })
|
|
.then((templateResult) => {
|
|
if (sequence !== requestSequence.current) return;
|
|
setTemplates(
|
|
templateResult.items.filter((item) => item.auditStatus !== 'deleted' && item.auditStatus !== 'disabled'),
|
|
);
|
|
setTotal(templateResult.total);
|
|
setError('');
|
|
})
|
|
.catch((reason: Error) => {
|
|
if (sequence === requestSequence.current) setError(reason.message || '短信模板加载失败');
|
|
})
|
|
.finally(() => {
|
|
if (sequence === requestSequence.current) setLoading(false);
|
|
});
|
|
}
|
|
|
|
useEffect(() => {
|
|
startTransition(() => loadData(page));
|
|
}, [appliedKeyword, page]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
void Promise.all([clientApi.listApplicationOptions(), clientApi.listSignatureOptions()])
|
|
.then(([applicationItems, signatureItems]) => {
|
|
if (cancelled) return;
|
|
setApplications(applicationItems.filter((item) => item.status === 'active'));
|
|
setSignatures(signatureItems);
|
|
})
|
|
.catch((reason: Error) => {
|
|
if (!cancelled) setError(reason.message || '模板选项加载失败');
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
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 = modalTemplate && modalTemplate !== 'new' ? modalTemplate : null;
|
|
try {
|
|
const payload = {
|
|
applicationId: state.applicationId,
|
|
signatureId: state.signatureId || undefined,
|
|
name: state.name,
|
|
content: state.content,
|
|
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 : '模板提交失败');
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section className="page-stack client-templates-page">
|
|
<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="client-template-toolbar">
|
|
<Input
|
|
onChange={(event) => setKeyword(event.target.value)}
|
|
placeholder="搜索模板名称、应用、签名或内容"
|
|
prefix={<Search size={17} />}
|
|
value={keyword}
|
|
/>
|
|
<div className="ui-query-actions">
|
|
<Button
|
|
icon={<Search size={17} />}
|
|
onClick={() => {
|
|
setPage(1);
|
|
setAppliedKeyword(keyword.trim());
|
|
}}
|
|
>
|
|
查询
|
|
</Button>
|
|
<Button
|
|
onClick={() => {
|
|
setKeyword('');
|
|
setPage(1);
|
|
setAppliedKeyword('');
|
|
}}
|
|
variant="ghost"
|
|
>
|
|
重置
|
|
</Button>
|
|
</div>
|
|
<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="client-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="client-template-footer">
|
|
<span>{formatDateTime(template.updatedAt)}</span>
|
|
<div>
|
|
<Button
|
|
icon={<Edit3 size={14} />}
|
|
onClick={() => setModalTemplate(template)}
|
|
size="sm"
|
|
variant="ghost"
|
|
>
|
|
编辑
|
|
</Button>
|
|
<DeleteRiskAction
|
|
onCompleted={() => void loadData()}
|
|
portal="client"
|
|
targetId={template.id}
|
|
targetType="template"
|
|
/>
|
|
</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={total}
|
|
/>
|
|
{!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>
|
|
);
|
|
}
|