fix: connect remaining sms pages to real backend
This commit is contained in:
@@ -1,207 +1,76 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { MessageSquare, Plus, Search, Info } from 'lucide-react';
|
||||
import { Button, Input, Modal, Select, Textarea } from '@/components/ui';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { MessageSquare, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
|
||||
import { clientApi, type ClientSmsApplication, type ClientSmsTemplate } from '@/api/adminApi';
|
||||
|
||||
type TemplateAccent = 'green' | 'blue' | 'red';
|
||||
|
||||
type SmsTemplateCard = {
|
||||
id: string;
|
||||
name: string;
|
||||
application: string;
|
||||
hash: string;
|
||||
content: string;
|
||||
variables: string[];
|
||||
updatedAt: string;
|
||||
accent: TemplateAccent;
|
||||
const statusTone: Record<string, 'success' | 'info' | 'danger' | 'warning'> = {
|
||||
approved: 'success',
|
||||
pending: 'info',
|
||||
rejected: 'danger',
|
||||
draft: 'warning',
|
||||
};
|
||||
|
||||
const recommendedVariables = [
|
||||
['验证码', 'code'],
|
||||
['手机号', 'phone'],
|
||||
['姓名', 'name'],
|
||||
['日期', 'date'],
|
||||
['金额', 'amount'],
|
||||
['时间', 'time'],
|
||||
['余额', 'balance'],
|
||||
['地址', 'address'],
|
||||
['天数', 'days'],
|
||||
['快递单号', 'trackingNumber'],
|
||||
['案件号', 'caseNumber'],
|
||||
['课程名称', 'courseName'],
|
||||
['链接', 'link'],
|
||||
['站点', 'station'],
|
||||
];
|
||||
|
||||
const initialTemplates: SmsTemplateCard[] = [
|
||||
{
|
||||
id: 'tpl-1',
|
||||
name: '营销领券',
|
||||
application: '营销推广平台',
|
||||
hash: '1c37f4da7c4a4a63',
|
||||
content: '尊敬的${time}客户!您于${time}在有效期${expiryTime},基础${party}有优惠元。',
|
||||
variables: ['time', 'time', 'expiryTime', 'party'],
|
||||
updatedAt: '2026-01-04 17:45:36',
|
||||
accent: 'green',
|
||||
},
|
||||
{
|
||||
id: 'tpl-2',
|
||||
name: '南通申诉受理',
|
||||
application: '客户服务系统',
|
||||
hash: '8e8a32c9d7b14c6a',
|
||||
content: '尊敬的${caseNumber}客户!您的${responder}已受理,当事人:${responder},当联总台/本人在任你定义您的档案表返。${url}。',
|
||||
variables: ['caseNumber', 'responder', 'responder', 'url'],
|
||||
updatedAt: '2026-01-04 17:46:30',
|
||||
accent: 'blue',
|
||||
},
|
||||
{
|
||||
id: 'tpl-3',
|
||||
name: '商城通知',
|
||||
application: '营销推广平台',
|
||||
hash: '9f8b2d3e5a7c4f2',
|
||||
content: '亲爱的${username},您的订单已发货,预计${days}个工作日送达。物流单号:${trackingNumber},可通过官网查询物流信息。',
|
||||
variables: ['username', 'days', 'trackingNumber'],
|
||||
updatedAt: '2026-01-03 14:20:16',
|
||||
accent: 'green',
|
||||
},
|
||||
{
|
||||
id: 'tpl-4',
|
||||
name: '支付通知',
|
||||
application: '客户服务系统',
|
||||
hash: '7d6c43e21f9a5d8',
|
||||
content: '尊敬的客户,您的账户已收到${date}的款项${amount}元,账户余额${balance}元。如有疑问请联系客服${phone}。',
|
||||
variables: ['date', 'amount', 'balance', 'phone'],
|
||||
updatedAt: '2026-01-04 08:30:22',
|
||||
accent: 'green',
|
||||
},
|
||||
{
|
||||
id: 'tpl-5',
|
||||
name: '课程提醒',
|
||||
application: '营销推广平台',
|
||||
hash: '3a5b678d9ef42aa',
|
||||
content: '${name}同学您好,您预约的${courseName}课程将于${time}开始,请提前进入直播间,课程链接:${link}',
|
||||
variables: ['name', 'courseName', 'time', 'link'],
|
||||
updatedAt: '2026-01-03 16:55:40',
|
||||
accent: 'blue',
|
||||
},
|
||||
{
|
||||
id: 'tpl-6',
|
||||
name: '派件通知',
|
||||
application: '客户服务系统',
|
||||
hash: '6e4f23ad4c7d8e1',
|
||||
content: '${name}您的快递已到达${station},快递员${courier}正在派件中:${address}。',
|
||||
variables: ['name', 'station', 'courier', 'address'],
|
||||
updatedAt: '2026-01-04 11:20:18',
|
||||
accent: 'red',
|
||||
},
|
||||
];
|
||||
const statusLabel: Record<string, string> = {
|
||||
approved: '已通过',
|
||||
pending: '审核中',
|
||||
rejected: '已驳回',
|
||||
draft: '草稿',
|
||||
disabled: '已禁用',
|
||||
};
|
||||
|
||||
function extractVariables(content: string) {
|
||||
return Array.from(content.matchAll(/\$\{([^}]+)\}/g)).map((match) => match[1]);
|
||||
}
|
||||
|
||||
function TemplateModal({
|
||||
mode,
|
||||
template,
|
||||
onClose,
|
||||
}: {
|
||||
mode: 'add' | 'edit';
|
||||
template?: SmsTemplateCard;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [content, setContent] = useState(template?.content ?? '');
|
||||
const [variablesOpen, setVariablesOpen] = useState(false);
|
||||
const variables = extractVariables(content);
|
||||
const wordCount = content.length;
|
||||
const billingCount = Math.max(1, Math.ceil(wordCount / 70));
|
||||
|
||||
function insertVariable(name: string) {
|
||||
setContent((current) => `${current}\${${name}}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>取消</Button>
|
||||
<Button onClick={onClose}>确认</Button>
|
||||
</>
|
||||
}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>{mode === 'edit' ? '编辑模板' : '添加模板'}</h2><p>请填写模板信息</p></div>}
|
||||
>
|
||||
<div className="template-form">
|
||||
<Select
|
||||
label="* 应用:"
|
||||
defaultValue={template?.application ?? ''}
|
||||
options={[
|
||||
{ label: '请选择应用', value: '' },
|
||||
{ label: '营销推广平台', value: '营销推广平台' },
|
||||
{ label: '客户服务系统', value: '客户服务系统' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="* 签名:"
|
||||
options={[
|
||||
{ label: '请选择签名', value: '' },
|
||||
{ label: '【科技公司】', value: '科技公司' },
|
||||
{ label: '【客户服务】', value: '客户服务' },
|
||||
]}
|
||||
/>
|
||||
<Textarea
|
||||
label="* 模板内容:"
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
placeholder="请输入模板内容"
|
||||
value={content}
|
||||
/>
|
||||
<div className="template-form-meta">
|
||||
<button onClick={() => setVariablesOpen((current) => !current)} type="button">
|
||||
+ {variablesOpen ? '收起变量面板' : '插入变量'}
|
||||
</button>
|
||||
<span>{wordCount} 字符(不含变量),计费 {billingCount} 条</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 placeholder="英文字符或数字" />
|
||||
<Button onClick={() => insertVariable('custom')}>插入</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="template-info-tip">
|
||||
<Info size={18} />
|
||||
<span>短信字数=签名+模板内容+变量内容,普通短信 70 字符计费 1 条,长短信 67 字符计算为 1 条短信(包含标点符号和空格)</span>
|
||||
</div>
|
||||
{variables.length ? (
|
||||
<div className="template-current-vars">
|
||||
<span>已识别变量:</span>
|
||||
{variables.map((item, index) => <strong key={`${item}-${index}`}>${`{${item}}`}</strong>)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
return Array.from(new Set(Array.from(content.matchAll(/\$\{([^}]+)\}/g)).map((match) => match[1])));
|
||||
}
|
||||
|
||||
export function ClientTemplatesPage() {
|
||||
const [templates, setTemplates] = useState(initialTemplates);
|
||||
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
||||
const [templates, setTemplates] = useState<ClientSmsTemplate[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [modalState, setModalState] = useState<{ mode: 'add' | 'edit'; template?: SmsTemplateCard } | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [applicationId, setApplicationId] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
|
||||
const filteredTemplates = templates.filter((item) => (
|
||||
item.name.includes(keyword) || item.application.includes(keyword)
|
||||
));
|
||||
function loadData() {
|
||||
setLoading(true);
|
||||
Promise.all([clientApi.listApplications(), clientApi.listTemplates()])
|
||||
.then(([applicationItems, templateItems]) => {
|
||||
setApplications(applicationItems.filter((item) => item.status === 'active'));
|
||||
setTemplates(templateItems.filter((item) => item.auditStatus !== 'deleted' && item.auditStatus !== 'disabled'));
|
||||
setError('');
|
||||
})
|
||||
.catch((reason: Error) => setError(reason.message || '短信模板加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
function deleteTemplate(id: string) {
|
||||
setTemplates((items) => items.filter((item) => item.id !== id));
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const filteredTemplates = useMemo(() => templates.filter((item) => (
|
||||
!keyword || [item.name, item.content, item.application?.name].join(' ').includes(keyword)
|
||||
)), [keyword, templates]);
|
||||
|
||||
function createTemplate() {
|
||||
const variables = extractVariables(content).map((variable) => ({ name: variable, required: true }));
|
||||
clientApi.createTemplate({ applicationId, name, content, variables })
|
||||
.then((created) => clientApi.submitTemplate(created.id))
|
||||
.then(() => {
|
||||
setModalOpen(false);
|
||||
setApplicationId('');
|
||||
setName('');
|
||||
setContent('');
|
||||
loadData();
|
||||
})
|
||||
.catch((reason: Error) => setError(reason.message || '模板提交失败'));
|
||||
}
|
||||
|
||||
function disableTemplate(id: string) {
|
||||
clientApi.changeTemplateStatus(id, 'disabled')
|
||||
.then(loadData)
|
||||
.catch((reason: Error) => setError(reason.message || '模板禁用失败'));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -218,42 +87,66 @@ export function ClientTemplatesPage() {
|
||||
<div className="template-toolbar">
|
||||
<Input
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索模板名称或应用..."
|
||||
placeholder="搜索模板名称、应用或内容"
|
||||
prefix={<Search size={17} />}
|
||||
value={keyword}
|
||||
/>
|
||||
<Button icon={<Plus size={17} />} onClick={() => setModalState({ mode: 'add' })}>添加短信模板</Button>
|
||||
<Button icon={<Plus size={17} />} onClick={() => setModalOpen(true)}>添加短信模板</Button>
|
||||
</div>
|
||||
{loading ? <p className="muted">正在加载短信模板...</p> : null}
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
<div className="template-card-grid">
|
||||
{filteredTemplates.map((template) => (
|
||||
<article className={`template-card template-card--${template.accent}`} key={template.id}>
|
||||
<h2>{template.name}</h2>
|
||||
<p className="muted">{template.application}</p>
|
||||
<p className="template-hash">{template.hash}</p>
|
||||
<p className="template-content">{template.content}</p>
|
||||
<div className="template-vars">
|
||||
<span>变量:</span>
|
||||
{template.variables.map((item, index) => <strong key={`${item}-${index}`}>${`{${item}}`}</strong>)}
|
||||
</div>
|
||||
<div className="template-card-footer">
|
||||
<span>{template.updatedAt}</span>
|
||||
<div>
|
||||
<button onClick={() => setModalState({ mode: 'edit', template })} type="button">编辑</button>
|
||||
<button onClick={() => deleteTemplate(template.id)} type="button">删除</button>
|
||||
{filteredTemplates.map((template) => {
|
||||
const variables = template.variables?.map((item) => item.name) ?? extractVariables(template.content);
|
||||
return (
|
||||
<article className="template-card template-card--green" key={template.id}>
|
||||
<h2>{template.name}</h2>
|
||||
<p className="muted">{template.application?.name ?? template.applicationId}</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>
|
||||
</article>
|
||||
))}
|
||||
<div className="template-card-footer">
|
||||
<span>{template.updatedAt}</span>
|
||||
<div>
|
||||
<button onClick={() => disableTemplate(template.id)} type="button">
|
||||
<Trash2 size={14} />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!loading && !error && filteredTemplates.length === 0 ? <p className="muted">暂无短信模板。</p> : null}
|
||||
|
||||
{modalState ? (
|
||||
<TemplateModal
|
||||
mode={modalState.mode}
|
||||
onClose={() => setModalState(null)}
|
||||
template={modalState.template}
|
||||
/>
|
||||
) : null}
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => setModalOpen(false)} variant="ghost">取消</Button>
|
||||
<Button disabled={!applicationId || !name || !content} onClick={createTemplate}>提交审核</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setModalOpen(false)}
|
||||
open={modalOpen}
|
||||
size="xl"
|
||||
title="添加短信模板"
|
||||
>
|
||||
<div className="template-form">
|
||||
<Select
|
||||
label="短信应用"
|
||||
onChange={(event) => setApplicationId(event.target.value)}
|
||||
options={[{ label: '请选择应用', value: '' }, ...applications.map((item) => ({ label: item.name, value: item.id }))]}
|
||||
value={applicationId}
|
||||
/>
|
||||
<Input label="模板名称" onChange={(event) => setName(event.target.value)} placeholder="请输入模板名称" value={name} />
|
||||
<Textarea label="模板内容" onChange={(event) => setContent(event.target.value)} placeholder="变量格式:${code}" rows={5} value={content} />
|
||||
</div>
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user