fix: connect remaining sms pages to real backend

This commit is contained in:
hectorzhao
2026-07-02 19:30:04 +08:00
parent 06562e42dd
commit 131f344ac4
50 changed files with 2074 additions and 6400 deletions
+113 -220
View File
@@ -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>
);
}